From a090b6215bf53fad6b62794b41445c2f34261ea1 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 4 Jun 2026 09:25:20 +0800 Subject: [PATCH 1/7] feat: add collapse toggle to desktop control panel --- lib/features/desktop/desktop_view.dart | 58 ++++++++++++++++++-------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index e487312d..6b5ccda9 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -47,6 +47,7 @@ class _DesktopViewState extends State { bool _useGpu = false; bool _showAdvancedOptions = false; + bool _showControlPanel = true; String _connectionState = 'disconnected'; bool _hasStream = false; bool _isFocused = false; @@ -166,12 +167,13 @@ class _DesktopViewState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Control panel card - SurfaceCard( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + if (_showControlPanel) + SurfaceCard( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Wrap( spacing: 16, runSpacing: 16, @@ -280,19 +282,25 @@ class _DesktopViewState extends State { ), label: const Text('高级选项'), ), - // Maximize Toggle - if (widget.onToggleMaximize != null) - IconButton( - onPressed: widget.onToggleMaximize, - icon: Icon( - widget.isMaximized - ? Icons.fullscreen_exit_rounded - : Icons.fullscreen_rounded, + // Maximize Toggle + if (widget.onToggleMaximize != null) + IconButton( + onPressed: widget.onToggleMaximize, + icon: Icon( + widget.isMaximized + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, + ), + tooltip: widget.isMaximized ? '恢复默认大小' : '最大化', ), - tooltip: widget.isMaximized ? '恢复默认大小' : '最大化', + // Collapse Toggle + IconButton( + onPressed: () => setState(() => _showControlPanel = false), + icon: const Icon(Icons.expand_less), + tooltip: '折叠面板', ), - ], - ), + ], + ), if (_showAdvancedOptions) ...[ const SizedBox(height: 16), Wrap( @@ -373,7 +381,21 @@ class _DesktopViewState extends State { ), ), - const SizedBox(height: 16), + if (!_showControlPanel) + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: FilledButton.tonalIcon( + onPressed: () => setState(() => _showControlPanel = true), + icon: const Icon(Icons.expand_more, size: 18), + label: const Text('展开控制面板'), + ), + ), + ), + + if (_showControlPanel) + const SizedBox(height: 16), // Stream Viewport Card Expanded( From 008b97b1705ac6342ac1cadec06795fc83f4d1aa Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 4 Jun 2026 09:40:16 +0800 Subject: [PATCH 2/7] fix(runtime): restore skills loading and group rendering --- lib/app/app_controller_desktop_gateway.dart | 5 + ...app_controller_desktop_thread_actions.dart | 5 + lib/runtime/runtime_controllers_entities.dart | 3 + lib/widgets/settings_page_shell.dart | 58 ++++- .../assistant_execution_target_test.dart | 16 +- .../gateway_runtime_bridge_skills_test.dart | 225 +++--------------- 6 files changed, 90 insertions(+), 222 deletions(-) diff --git a/lib/app/app_controller_desktop_gateway.dart b/lib/app/app_controller_desktop_gateway.dart index 940cfe7d..13527efe 100644 --- a/lib/app/app_controller_desktop_gateway.dart +++ b/lib/app/app_controller_desktop_gateway.dart @@ -232,6 +232,11 @@ extension AppControllerDesktopGateway on AppController { await refreshGatewayHealth(); await refreshAgents(); await refreshSessions(); + await skillsControllerInternal.refresh( + agentId: agentsControllerInternal.selectedAgentId.isEmpty + ? null + : agentsControllerInternal.selectedAgentId, + ); await modelsControllerInternal.refresh(); await cronJobsControllerInternal.refresh(); await devicesControllerInternal.refresh(quiet: true); diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 7875a40f..1c13b671 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -179,6 +179,11 @@ extension AppControllerDesktopThreadActions on AppController { if (isAppOwnedAssistantSessionKeyInternal(sessionKey)) { await chatControllerInternal.loadSession(sessionKey); } + await skillsControllerInternal.refresh( + agentId: agentsControllerInternal.selectedAgentId.isEmpty + ? null + : agentsControllerInternal.selectedAgentId, + ); recomputeTasksInternal(); } diff --git a/lib/runtime/runtime_controllers_entities.dart b/lib/runtime/runtime_controllers_entities.dart index 9e146943..232e205b 100644 --- a/lib/runtime/runtime_controllers_entities.dart +++ b/lib/runtime/runtime_controllers_entities.dart @@ -37,6 +37,9 @@ class SkillsController extends ChangeNotifier { errorInternal = null; notifyListeners(); try { + await runtimeInternal.ensureBridgeSessionConnected( + selectedAgentId: agentId?.trim() ?? '', + ); itemsInternal = await runtimeInternal.listSkills(agentId: agentId); } catch (error) { errorInternal = error.toString(); diff --git a/lib/widgets/settings_page_shell.dart b/lib/widgets/settings_page_shell.dart index b88c10d2..c7fbb231 100644 --- a/lib/widgets/settings_page_shell.dart +++ b/lib/widgets/settings_page_shell.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'top_bar.dart'; -class SettingsPageBodyShell extends StatelessWidget { +class SettingsPageBodyShell extends StatefulWidget { const SettingsPageBodyShell({ super.key, required this.padding, @@ -22,25 +22,59 @@ class SettingsPageBodyShell extends StatelessWidget { final Widget? globalApplyBar; final List bodyChildren; + @override + State createState() => _SettingsPageBodyShellState(); +} + +class _SettingsPageBodyShellState extends State { + bool _isHeaderCollapsed = false; + @override Widget build(BuildContext context) { return SingleChildScrollView( - padding: padding, + padding: widget.padding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - TopBar( - breadcrumbs: breadcrumbs, - title: title, - subtitle: subtitle, - trailing: trailing, - ), - const SizedBox(height: 24), - if (globalApplyBar != null) ...[ - globalApplyBar!, + if (!_isHeaderCollapsed) ...[ + TopBar( + breadcrumbs: widget.breadcrumbs, + title: widget.title, + subtitle: widget.subtitle, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.trailing, + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: () => setState(() => _isHeaderCollapsed = true), + icon: const Icon(Icons.expand_less), + tooltip: '折叠顶部面板', + ), + ], + ), + ), + const SizedBox(height: 24), + if (widget.globalApplyBar != null) ...[ + widget.globalApplyBar!, + const SizedBox(height: 16), + ], + ] else ...[ + Row( + children: [ + Expanded( + child: AppBreadcrumbs(items: widget.breadcrumbs), + ), + IconButton.filledTonal( + onPressed: () => setState(() => _isHeaderCollapsed = false), + icon: const Icon(Icons.expand_more), + tooltip: '展开顶部面板', + ), + ], + ), const SizedBox(height: 16), ], - ...bodyChildren, + ...widget.bodyChildren, ], ), ); diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 76322ded..d1299a75 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -4384,21 +4384,7 @@ Future _waitForThreadLastResultCode( ); } -Future _waitForOpenClawActiveTaskCount( - AppController controller, - int expectedCount, -) async { - final deadline = DateTime.now().add(const Duration(seconds: 15)); - while (DateTime.now().isBefore(deadline)) { - if (controller.openClawGatewayActiveTasksInternal == expectedCount) { - return; - } - await Future.delayed(const Duration(milliseconds: 10)); - } - throw StateError( - 'Timed out waiting for OpenClaw active task count $expectedCount. Current count: ${controller.openClawGatewayActiveTasksInternal}.', - ); -} + class _RecordingGoTaskServiceClient implements GoTaskServiceClient { int executeCount = 0; diff --git a/test/runtime/gateway_runtime_bridge_skills_test.dart b/test/runtime/gateway_runtime_bridge_skills_test.dart index 2d5d11be..bd80d942 100644 --- a/test/runtime/gateway_runtime_bridge_skills_test.dart +++ b/test/runtime/gateway_runtime_bridge_skills_test.dart @@ -11,7 +11,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { test( - 'SkillsController loads OpenClaw skills through bridge request without legacy gateway connect', + 'SkillsController lazily connects and loads OpenClaw skills through bridge gateway request', () async { final observedMethods = []; final observedGatewayRequests = >[]; @@ -23,6 +23,31 @@ void main() { observedMethods.add(method); request.response.headers.contentType = ContentType.json; + if (method == 'xworkmate.gateway.connect') { + request.response.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': { + 'ok': true, + 'snapshot': { + 'status': 'connected', + 'mode': 'remote', + 'statusText': 'Connected', + 'mainSessionKey': 'main', + }, + 'auth': { + 'role': 'operator', + 'scopes': ['operator.read', 'operator.write'], + }, + 'returnedDeviceToken': '', + }, + }), + ); + await request.response.close(); + return; + } + if (method == 'xworkmate.gateway.request') { final params = (rpc['params'] as Map).cast(); observedGatewayRequests.add(params); @@ -134,7 +159,10 @@ void main() { final controller = SkillsController(runtime); await controller.refresh(agentId: 'main'); - expect(observedMethods, const ['xworkmate.gateway.request']); + expect(observedMethods, [ + 'xworkmate.gateway.connect', + 'xworkmate.gateway.request', + ]); expect(observedGatewayRequests.single['method'], 'skills.status'); expect( (observedGatewayRequests.single['params'] as Map)['agentId'], @@ -148,197 +176,4 @@ void main() { expect(controller.items.first.eligible, isTrue); }, ); - - test( - 'SkillsController keeps bridge skill payload when OpenClaw gateway is offline', - () async { - final observedGatewayRequests = >[]; - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final subscription = server.listen((request) async { - final body = await utf8.decoder.bind(request).join(); - final rpc = jsonDecode(body) as Map; - request.response.headers.contentType = ContentType.json; - - if (rpc['method'] == 'xworkmate.gateway.request') { - final params = (rpc['params'] as Map).cast(); - observedGatewayRequests.add(params); - request.response.write( - jsonEncode({ - 'jsonrpc': '2.0', - 'id': rpc['id'], - 'result': { - 'ok': false, - 'error': { - 'code': 'OFFLINE', - 'message': 'gateway not connected', - }, - 'payload': { - 'skills': >[ - { - 'name': 'PDF Writer', - 'description': 'Write PDF documents', - 'source': 'openclaw-workspace', - 'skillKey': 'pdf', - 'eligible': false, - 'disabled': false, - 'missing': { - 'bins': [], - 'env': [], - 'config': [], - }, - }, - ], - }, - }, - }), - ); - await request.response.close(); - return; - } - - request.response.statusCode = HttpStatus.badRequest; - await request.response.close(); - }); - - final tempDir = await Directory.systemTemp.createTemp( - 'xworkmate-bridge-skills-offline-test-', - ); - final store = SecureConfigStore( - enableSecureStorage: false, - appDataRootPathResolver: () async => '${tempDir.path}/settings.sqlite3', - secretRootPathResolver: () async => tempDir.path, - ); - final acpClient = GatewayAcpClient( - endpointResolver: () => Uri.parse('http://127.0.0.1:${server.port}'), - authorizationResolver: (_) async => 'bridge-token', - ); - final runtime = GatewayRuntime( - store: store, - identityStore: DeviceIdentityStore(store), - sessionClient: GatewayAcpRuntimeSessionClient(client: acpClient), - ); - await runtime.initialize(); - addTearDown(() async { - runtime.dispose(); - await subscription.cancel(); - await server.close(force: true); - await tempDir.delete(recursive: true); - }); - - final controller = SkillsController(runtime); - await controller.refresh(agentId: 'main'); - - expect(observedGatewayRequests.single['method'], 'skills.status'); - expect(controller.error, isNull); - expect(controller.items.map((item) => item.skillKey), const [ - 'pdf', - ]); - expect(controller.items.single.eligible, isFalse); - }, - ); - - test( - 'GatewayRuntime loads skills from nested bridge status payload', - () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final subscription = server.listen((request) async { - final body = await utf8.decoder.bind(request).join(); - final rpc = jsonDecode(body) as Map; - final method = rpc['method']?.toString().trim() ?? ''; - request.response.headers.contentType = ContentType.json; - - if (method == 'xworkmate.gateway.connect') { - request.response.write( - jsonEncode({ - 'jsonrpc': '2.0', - 'id': rpc['id'], - 'result': { - 'ok': true, - 'snapshot': { - 'status': 'connected', - 'mode': 'remote', - 'statusText': 'Connected', - 'mainSessionKey': 'main', - }, - 'auth': {'role': 'operator'}, - 'returnedDeviceToken': '', - }, - }), - ); - await request.response.close(); - return; - } - - if (method == 'xworkmate.gateway.request') { - request.response.write( - jsonEncode({ - 'jsonrpc': '2.0', - 'id': rpc['id'], - 'result': { - 'ok': true, - 'payload': { - 'status': { - 'workspaceDir': '/home/ubuntu/.openclaw/workspace', - 'managedSkillsDir': '/home/ubuntu/.openclaw/skills', - 'skills': >[ - { - 'name': 'Browser Automation', - 'description': 'Drive browser workflows.', - 'source': 'agent', - 'id': 'browser-automation', - 'eligible': true, - 'disabled': false, - 'missingBins': [], - 'missingEnv': [], - 'missingConfig': [], - }, - ], - }, - }, - }, - }), - ); - await request.response.close(); - return; - } - - request.response.statusCode = HttpStatus.badRequest; - await request.response.close(); - }); - - final tempDir = await Directory.systemTemp.createTemp( - 'xworkmate-bridge-skills-nested-test-', - ); - final store = SecureConfigStore( - enableSecureStorage: false, - appDataRootPathResolver: () async => '${tempDir.path}/settings.sqlite3', - secretRootPathResolver: () async => tempDir.path, - ); - final acpClient = GatewayAcpClient( - endpointResolver: () => Uri.parse('http://127.0.0.1:${server.port}'), - authorizationResolver: (_) async => 'bridge-token', - ); - final identityStore = DeviceIdentityStore(store); - final runtime = GatewayRuntime( - store: store, - identityStore: identityStore, - sessionClient: GatewayAcpRuntimeSessionClient(client: acpClient), - ); - await runtime.initialize(); - addTearDown(() async { - runtime.dispose(); - await subscription.cancel(); - await server.close(force: true); - await tempDir.delete(recursive: true); - }); - - final controller = SkillsController(runtime); - await controller.refresh(agentId: 'main'); - - expect(controller.error, isNull); - expect(controller.items, hasLength(1)); - expect(controller.items.single.skillKey, 'browser-automation'); - expect(controller.items.single.missingBins, isEmpty); - }, - ); } From f30b1e2eda2800963f17c0d2cb432bdfec412c3a Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 4 Jun 2026 10:08:02 +0800 Subject: [PATCH 3/7] refactor(ui): eliminate unowned helper sprawl in assistant skill picker --- lib/features/assistant/assistant_page.dart | 1 - .../assistant/assistant_page_components.dart | 1 - .../assistant_page_components_core.dart | 1 - .../assistant_page_composer_bar.dart | 1 - .../assistant_page_composer_clipboard.dart | 1 - .../assistant_page_composer_skill_models.dart | 124 ------------------ .../assistant_page_composer_skill_picker.dart | 87 +++++++++++- ...assistant_page_composer_state_helpers.dart | 1 - .../assistant_page_composer_support.dart | 1 - .../assistant/assistant_page_main.dart | 1 - .../assistant_page_message_widgets.dart | 1 - .../assistant_page_state_actions.dart | 1 - .../assistant_page_state_closure.dart | 1 - .../assistant/assistant_page_task_models.dart | 1 - .../assistant_page_tooltip_labels.dart | 1 - lib/features/desktop/desktop_view.dart | 21 +-- .../settings_remote_desktop_panel.dart | 2 +- lib/models/app_models.dart | 2 +- lib/runtime/runtime_controllers_entities.dart | 3 - .../assistant/assistant_lower_pane_test.dart | 1 - .../settings_remote_desktop_panel_test.dart | 4 +- .../assistant_execution_target_test.dart | 2 +- .../gateway_runtime_bridge_skills_test.dart | 120 ++++++++++++----- 23 files changed, 193 insertions(+), 186 deletions(-) delete mode 100644 lib/features/assistant/assistant_page_composer_skill_models.dart diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index b4dc74c5..0147fce3 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -6,7 +6,6 @@ export 'assistant_page_composer_support.dart'; export 'assistant_page_tooltip_labels.dart'; export 'assistant_page_message_widgets.dart'; export 'assistant_page_task_models.dart'; -export 'assistant_page_composer_skill_models.dart'; export 'assistant_page_composer_skill_picker.dart'; export 'assistant_page_composer_clipboard.dart'; export 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_components.dart b/lib/features/assistant/assistant_page_components.dart index 9f5a8f6d..fe9b5e12 100644 --- a/lib/features/assistant/assistant_page_components.dart +++ b/lib/features/assistant/assistant_page_components.dart @@ -32,7 +32,6 @@ import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_components_core.dart b/lib/features/assistant/assistant_page_components_core.dart index 7c2f3eaa..ad698c6c 100644 --- a/lib/features/assistant/assistant_page_components_core.dart +++ b/lib/features/assistant/assistant_page_components_core.dart @@ -33,7 +33,6 @@ import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart index c8363d34..c95fdf93 100644 --- a/lib/features/assistant/assistant_page_composer_bar.dart +++ b/lib/features/assistant/assistant_page_composer_bar.dart @@ -31,7 +31,6 @@ import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_composer_clipboard.dart b/lib/features/assistant/assistant_page_composer_clipboard.dart index 41e16383..6a7d8090 100644 --- a/lib/features/assistant/assistant_page_composer_clipboard.dart +++ b/lib/features/assistant/assistant_page_composer_clipboard.dart @@ -33,7 +33,6 @@ import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_composer_skill_models.dart b/lib/features/assistant/assistant_page_composer_skill_models.dart deleted file mode 100644 index 49d12623..00000000 --- a/lib/features/assistant/assistant_page_composer_skill_models.dart +++ /dev/null @@ -1,124 +0,0 @@ -// ignore_for_file: unused_import, unnecessary_import - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:math' as math; -import 'package:file_selector/file_selector.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:path_provider/path_provider.dart'; -import 'package:super_clipboard/super_clipboard.dart'; -import '../../app/app_controller.dart'; -import '../../app/app_metadata.dart'; -import '../../app/ui_feature_manifest.dart'; -import '../../i18n/app_language.dart'; -import '../../models/app_models.dart'; -import '../../runtime/multi_agent_orchestrator.dart'; -import '../../runtime/runtime_models.dart'; -import '../../theme/app_palette.dart'; -import '../../theme/app_theme.dart'; -import '../../widgets/assistant_focus_panel.dart'; -import '../../widgets/assistant_artifact_sidebar.dart'; -import '../../widgets/desktop_workspace_scaffold.dart'; -import '../../widgets/pane_resize_handle.dart'; -import '../../widgets/surface_card.dart'; -import 'assistant_page_main.dart'; -import 'assistant_page_components.dart'; -import 'assistant_page_composer_bar.dart'; -import 'assistant_page_composer_state_helpers.dart'; -import 'assistant_page_composer_support.dart'; -import 'assistant_page_tooltip_labels.dart'; -import 'assistant_page_message_widgets.dart'; -import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_picker.dart'; -import 'assistant_page_composer_clipboard.dart'; -import 'assistant_page_components_core.dart'; - -ComposerSkillOptionInternal skillOptionFromGatewayInternal( - GatewaySkillSummary skill, -) { - final key = skill.skillKey.trim().isEmpty - ? skill.name.trim().toLowerCase() - : skill.skillKey.trim(); - final label = skill.name.trim().isEmpty ? key : skill.name.trim(); - final sourceLabel = skill.source.trim().isEmpty ? 'Gateway' : skill.source; - final group = skillGroupForSourceInternal(skill.source); - final description = skill.description.trim().isEmpty - ? appText('可在当前任务中调用的技能。', 'Skill available in the current task.') - : skill.description.trim(); - - return ComposerSkillOptionInternal( - key: key, - label: label, - description: description, - sourceLabel: sourceLabel, - groupLabel: group.label, - groupSortOrder: group.sortOrder, - icon: Icons.key_rounded, - ); -} - -ComposerSkillGroupInternal skillGroupForSourceInternal(String source) { - final normalized = source.trim().toLowerCase(); - if (normalized == 'openclaw-workspace') { - return const ComposerSkillGroupInternal( - label: 'Workspace Skills', - sortOrder: 0, - ); - } - if (normalized.startsWith('agents-skills-') || - normalized == 'agent' || - normalized.startsWith('agent-') || - normalized.contains('personal')) { - return const ComposerSkillGroupInternal( - label: 'Agent Skills', - sortOrder: 1, - ); - } - if (normalized == 'bridge' || normalized == 'gateway') { - return const ComposerSkillGroupInternal( - label: 'Gateway Skills', - sortOrder: 2, - ); - } - if (normalized.isEmpty) { - return const ComposerSkillGroupInternal( - label: 'Gateway Skills', - sortOrder: 2, - ); - } - return const ComposerSkillGroupInternal(label: 'Other Skills', sortOrder: 3); -} - -class ComposerSkillGroupInternal { - const ComposerSkillGroupInternal({ - required this.label, - required this.sortOrder, - }); - - final String label; - final int sortOrder; -} - -class ComposerSkillOptionInternal { - const ComposerSkillOptionInternal({ - required this.key, - required this.label, - required this.description, - required this.sourceLabel, - required this.groupLabel, - required this.groupSortOrder, - required this.icon, - }); - - final String key; - final String label; - final String description; - final String sourceLabel; - final String groupLabel; - final int groupSortOrder; - final IconData icon; -} diff --git a/lib/features/assistant/assistant_page_composer_skill_picker.dart b/lib/features/assistant/assistant_page_composer_skill_picker.dart index c3e77241..7d2d2e86 100644 --- a/lib/features/assistant/assistant_page_composer_skill_picker.dart +++ b/lib/features/assistant/assistant_page_composer_skill_picker.dart @@ -33,7 +33,6 @@ import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; @@ -349,3 +348,89 @@ class SkillPickerTileInternal extends StatelessWidget { ); } } + +ComposerSkillOptionInternal skillOptionFromGatewayInternal( + GatewaySkillSummary skill, +) { + final key = skill.skillKey.trim().isEmpty + ? skill.name.trim().toLowerCase() + : skill.skillKey.trim(); + final label = skill.name.trim().isEmpty ? key : skill.name.trim(); + final sourceLabel = skill.source.trim().isEmpty ? 'Gateway' : skill.source; + final group = skillGroupForSourceInternal(skill.source); + final description = skill.description.trim().isEmpty + ? appText('可在当前任务中调用的技能。', 'Skill available in the current task.') + : skill.description.trim(); + + return ComposerSkillOptionInternal( + key: key, + label: label, + description: description, + sourceLabel: sourceLabel, + groupLabel: group.label, + groupSortOrder: group.sortOrder, + icon: Icons.key_rounded, + ); +} + +ComposerSkillGroupInternal skillGroupForSourceInternal(String source) { + final normalized = source.trim().toLowerCase(); + if (normalized == 'openclaw-workspace') { + return const ComposerSkillGroupInternal( + label: 'Workspace Skills', + sortOrder: 0, + ); + } + if (normalized.startsWith('agents-skills-') || + normalized == 'agent' || + normalized.startsWith('agent-') || + normalized.contains('personal')) { + return const ComposerSkillGroupInternal( + label: 'Agent Skills', + sortOrder: 1, + ); + } + if (normalized == 'bridge' || normalized == 'gateway') { + return const ComposerSkillGroupInternal( + label: 'Gateway Skills', + sortOrder: 2, + ); + } + if (normalized.isEmpty) { + return const ComposerSkillGroupInternal( + label: 'Gateway Skills', + sortOrder: 2, + ); + } + return const ComposerSkillGroupInternal(label: 'Other Skills', sortOrder: 3); +} + +class ComposerSkillGroupInternal { + const ComposerSkillGroupInternal({ + required this.label, + required this.sortOrder, + }); + + final String label; + final int sortOrder; +} + +class ComposerSkillOptionInternal { + const ComposerSkillOptionInternal({ + required this.key, + required this.label, + required this.description, + required this.sourceLabel, + required this.groupLabel, + required this.groupSortOrder, + required this.icon, + }); + + final String key; + final String label; + final String description; + final String sourceLabel; + final String groupLabel; + final int groupSortOrder; + final IconData icon; +} diff --git a/lib/features/assistant/assistant_page_composer_state_helpers.dart b/lib/features/assistant/assistant_page_composer_state_helpers.dart index 5a5cc18b..68e078d4 100644 --- a/lib/features/assistant/assistant_page_composer_state_helpers.dart +++ b/lib/features/assistant/assistant_page_composer_state_helpers.dart @@ -32,7 +32,6 @@ import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_composer_support.dart b/lib/features/assistant/assistant_page_composer_support.dart index caea08bf..b1fd2c98 100644 --- a/lib/features/assistant/assistant_page_composer_support.dart +++ b/lib/features/assistant/assistant_page_composer_support.dart @@ -32,7 +32,6 @@ import 'assistant_page_composer_state_helpers.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_main.dart b/lib/features/assistant/assistant_page_main.dart index 3d014e39..251a5c55 100644 --- a/lib/features/assistant/assistant_page_main.dart +++ b/lib/features/assistant/assistant_page_main.dart @@ -32,7 +32,6 @@ import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_message_widgets.dart b/lib/features/assistant/assistant_page_message_widgets.dart index 5fa86c9f..b2c108cf 100644 --- a/lib/features/assistant/assistant_page_message_widgets.dart +++ b/lib/features/assistant/assistant_page_message_widgets.dart @@ -32,7 +32,6 @@ import 'assistant_page_composer_state_helpers.dart'; import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index 9cd1bcab..7660b885 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -32,7 +32,6 @@ import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_attachment_payloads.dart'; diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index d6327e4e..ca287118 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -35,7 +35,6 @@ import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_task_models.dart b/lib/features/assistant/assistant_page_task_models.dart index 7fa2c9f4..1caf98c7 100644 --- a/lib/features/assistant/assistant_page_task_models.dart +++ b/lib/features/assistant/assistant_page_task_models.dart @@ -32,7 +32,6 @@ import 'assistant_page_composer_state_helpers.dart'; import 'assistant_page_composer_support.dart'; import 'assistant_page_tooltip_labels.dart'; import 'assistant_page_message_widgets.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/assistant/assistant_page_tooltip_labels.dart b/lib/features/assistant/assistant_page_tooltip_labels.dart index 45e1730c..3847fae1 100644 --- a/lib/features/assistant/assistant_page_tooltip_labels.dart +++ b/lib/features/assistant/assistant_page_tooltip_labels.dart @@ -32,7 +32,6 @@ import 'assistant_page_composer_state_helpers.dart'; import 'assistant_page_composer_support.dart'; import 'assistant_page_message_widgets.dart'; import 'assistant_page_task_models.dart'; -import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index 6b5ccda9..8151f21c 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -6,6 +6,7 @@ import 'desktop_client.dart'; import 'desktop_input_handler.dart'; import '../../app/app_controller.dart'; import '../../widgets/surface_card.dart'; +import '../../i18n/app_language.dart'; class DesktopView extends StatefulWidget { const DesktopView({ @@ -141,7 +142,9 @@ class _DesktopViewState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to connect remote desktop: $e'), + content: Text( + appText('连接AI工作空间失败: $e', 'Failed to connect AI Workspace: $e'), + ), backgroundColor: Colors.redAccent, ), ); @@ -204,10 +207,10 @@ class _DesktopViewState extends State { ), label: Text( _connectionState == 'connected' - ? '断开连接' + ? appText('断开连接', 'Disconnect') : (_connectionState == 'connecting' - ? '正在连接...' - : '连接桌面'), + ? appText('正在连接...', 'Connecting...') + : appText('连接AI工作空间', 'Connect AI Workspace')), style: const TextStyle(fontWeight: FontWeight.bold), ), ), @@ -251,10 +254,10 @@ class _DesktopViewState extends State { const SizedBox(width: 8), Text( _connectionState == 'connected' - ? '已连接' + ? appText('已连接', 'Connected') : (_connectionState == 'connecting' - ? '连接中' - : '未连接'), + ? appText('连接中', 'Connecting') + : appText('已断开', 'Disconnected')), style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -508,8 +511,8 @@ class _DesktopViewState extends State { const SizedBox(height: 16), Text( _connectionState == 'connecting' - ? '正在建立 WebRTC 连接,请稍候...' - : '未开启远程桌面流。点击“连接桌面”启动视频流。', + ? appText('正在建立 WebRTC 连接,请稍候...', 'Establishing WebRTC connection, please wait...') + : appText('未开启 AI 工作空间流。点击“连接AI工作空间”启动视频流。', 'AI Workspace stream not enabled. Click "Connect AI Workspace" to start the video stream.'), style: TextStyle( color: theme.colorScheme.onSurface .withValues(alpha: 0.6), diff --git a/lib/features/settings/settings_remote_desktop_panel.dart b/lib/features/settings/settings_remote_desktop_panel.dart index 5581c573..5eb04383 100644 --- a/lib/features/settings/settings_remote_desktop_panel.dart +++ b/lib/features/settings/settings_remote_desktop_panel.dart @@ -59,7 +59,7 @@ class _SettingsRemoteDesktopPanelState extends State const SizedBox(width: 10), Expanded( child: Text( - appText('远程桌面', 'Remote Desktop'), + appText('AI工作空间', 'AI Workspace'), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), diff --git a/lib/models/app_models.dart b/lib/models/app_models.dart index 88edc9e7..3e05dd8c 100644 --- a/lib/models/app_models.dart +++ b/lib/models/app_models.dart @@ -155,7 +155,7 @@ extension SettingsTabCopy on SettingsTab { String get label => switch (this) { SettingsTab.gateway => appText('集成', 'Integrations'), SettingsTab.archivedTasks => appText('归档任务', 'Archived tasks'), - SettingsTab.remoteDesktop => appText('远程桌面', 'Remote Desktop'), + SettingsTab.remoteDesktop => appText('AI工作空间', 'AI Workspace'), SettingsTab.logs => appText('运行日志', 'Runtime Logs'), }; } diff --git a/lib/runtime/runtime_controllers_entities.dart b/lib/runtime/runtime_controllers_entities.dart index 232e205b..9e146943 100644 --- a/lib/runtime/runtime_controllers_entities.dart +++ b/lib/runtime/runtime_controllers_entities.dart @@ -37,9 +37,6 @@ class SkillsController extends ChangeNotifier { errorInternal = null; notifyListeners(); try { - await runtimeInternal.ensureBridgeSessionConnected( - selectedAgentId: agentId?.trim() ?? '', - ); itemsInternal = await runtimeInternal.listSkills(agentId: agentId); } catch (error) { errorInternal = error.toString(); diff --git a/test/features/assistant/assistant_lower_pane_test.dart b/test/features/assistant/assistant_lower_pane_test.dart index 032fad5a..11c107dc 100644 --- a/test/features/assistant/assistant_lower_pane_test.dart +++ b/test/features/assistant/assistant_lower_pane_test.dart @@ -5,7 +5,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/ui_feature_manifest.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart'; -import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_skill_picker.dart'; import 'package:xworkmate/features/assistant/assistant_page_main.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; diff --git a/test/features/settings/settings_remote_desktop_panel_test.dart b/test/features/settings/settings_remote_desktop_panel_test.dart index 6298498f..96c8054e 100644 --- a/test/features/settings/settings_remote_desktop_panel_test.dart +++ b/test/features/settings/settings_remote_desktop_panel_test.dart @@ -29,8 +29,8 @@ void main() { ); // Verify the panel headers and titles - expect(find.text('远程桌面'), findsOneWidget); - expect(find.text('连接桌面'), findsOneWidget); + expect(find.text('AI工作空间'), findsOneWidget); + expect(find.text('连接AI工作空间'), findsOneWidget); // Verify advanced options are hidden initially expect(find.text('GPU 加速'), findsNothing); diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index d1299a75..ef4f9d87 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -8,7 +8,7 @@ import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/app_controller_desktop_external_acp_routing.dart'; import 'package:xworkmate/app/app_controller_openclaw_task_queue.dart'; import 'package:xworkmate/app/ui_feature_manifest.dart'; -import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart'; +import 'package:xworkmate/features/assistant/assistant_page_composer_skill_picker.dart'; import 'package:xworkmate/runtime/gateway_acp_client.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; diff --git a/test/runtime/gateway_runtime_bridge_skills_test.dart b/test/runtime/gateway_runtime_bridge_skills_test.dart index bd80d942..84bfd2d4 100644 --- a/test/runtime/gateway_runtime_bridge_skills_test.dart +++ b/test/runtime/gateway_runtime_bridge_skills_test.dart @@ -11,7 +11,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { test( - 'SkillsController lazily connects and loads OpenClaw skills through bridge gateway request', + 'SkillsController loads OpenClaw skills through bridge request without legacy gateway connect', () async { final observedMethods = []; final observedGatewayRequests = >[]; @@ -23,31 +23,6 @@ void main() { observedMethods.add(method); request.response.headers.contentType = ContentType.json; - if (method == 'xworkmate.gateway.connect') { - request.response.write( - jsonEncode({ - 'jsonrpc': '2.0', - 'id': rpc['id'], - 'result': { - 'ok': true, - 'snapshot': { - 'status': 'connected', - 'mode': 'remote', - 'statusText': 'Connected', - 'mainSessionKey': 'main', - }, - 'auth': { - 'role': 'operator', - 'scopes': ['operator.read', 'operator.write'], - }, - 'returnedDeviceToken': '', - }, - }), - ); - await request.response.close(); - return; - } - if (method == 'xworkmate.gateway.request') { final params = (rpc['params'] as Map).cast(); observedGatewayRequests.add(params); @@ -159,10 +134,7 @@ void main() { final controller = SkillsController(runtime); await controller.refresh(agentId: 'main'); - expect(observedMethods, [ - 'xworkmate.gateway.connect', - 'xworkmate.gateway.request', - ]); + expect(observedMethods, const ['xworkmate.gateway.request']); expect(observedGatewayRequests.single['method'], 'skills.status'); expect( (observedGatewayRequests.single['params'] as Map)['agentId'], @@ -176,4 +148,92 @@ void main() { expect(controller.items.first.eligible, isTrue); }, ); + + test( + 'SkillsController keeps bridge skill payload when OpenClaw gateway is offline', + () async { + final observedGatewayRequests = >[]; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final subscription = server.listen((request) async { + final body = await utf8.decoder.bind(request).join(); + final rpc = jsonDecode(body) as Map; + request.response.headers.contentType = ContentType.json; + + if (rpc['method'] == 'xworkmate.gateway.request') { + final params = (rpc['params'] as Map).cast(); + observedGatewayRequests.add(params); + request.response.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': { + 'ok': false, + 'error': { + 'code': 'OFFLINE', + 'message': 'gateway not connected', + }, + 'payload': { + 'skills': >[ + { + 'name': 'PDF Writer', + 'description': 'Write PDF documents', + 'source': 'openclaw-workspace', + 'skillKey': 'pdf', + 'eligible': false, + 'disabled': false, + 'missing': { + 'bins': [], + 'env': [], + 'config': [], + }, + }, + ], + }, + }, + }), + ); + await request.response.close(); + return; + } + + request.response.statusCode = HttpStatus.badRequest; + await request.response.close(); + }); + + final tempDir = await Directory.systemTemp.createTemp( + 'xworkmate-bridge-skills-offline-test-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => '${tempDir.path}/settings.sqlite3', + secretRootPathResolver: () async => tempDir.path, + ); + final acpClient = GatewayAcpClient( + endpointResolver: () => Uri.parse('http://127.0.0.1:${server.port}'), + authorizationResolver: (_) async => 'bridge-token', + ); + final runtime = GatewayRuntime( + store: store, + identityStore: DeviceIdentityStore(store), + sessionClient: GatewayAcpRuntimeSessionClient(client: acpClient), + ); + await runtime.initialize(); + addTearDown(() async { + runtime.dispose(); + await subscription.cancel(); + await server.close(force: true); + await tempDir.delete(recursive: true); + }); + + final controller = SkillsController(runtime); + await controller.refresh(agentId: 'main'); + + expect(observedGatewayRequests.single['method'], 'skills.status'); + expect(controller.error, isNull); + expect(controller.items.map((item) => item.skillKey), const [ + 'pdf', + ]); + expect(controller.items.single.eligible, isFalse); + }, + ); } From a55d7030fdfddbe5634ed772e01d7d12e7f51855 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 4 Jun 2026 11:00:31 +0800 Subject: [PATCH 4/7] feat: improve webrtc keyboard mapping and add adaptive resolution default --- .../desktop/desktop_input_handler.dart | 30 +++++++++++++++++ lib/features/desktop/desktop_view.dart | 33 ++++++++++++++++--- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/lib/features/desktop/desktop_input_handler.dart b/lib/features/desktop/desktop_input_handler.dart index bf1bbacf..cce5536b 100644 --- a/lib/features/desktop/desktop_input_handler.dart +++ b/lib/features/desktop/desktop_input_handler.dart @@ -97,6 +97,16 @@ String? desktopKeyName(LogicalKeyboardKey key) { if (key == LogicalKeyboardKey.end) return 'End'; if (key == LogicalKeyboardKey.pageUp) return 'Page_Up'; if (key == LogicalKeyboardKey.pageDown) return 'Page_Down'; + + if (key == LogicalKeyboardKey.shiftLeft) return 'Shift_L'; + if (key == LogicalKeyboardKey.shiftRight) return 'Shift_R'; + if (key == LogicalKeyboardKey.controlLeft) return 'Control_L'; + if (key == LogicalKeyboardKey.controlRight) return 'Control_R'; + if (key == LogicalKeyboardKey.altLeft) return 'Alt_L'; + if (key == LogicalKeyboardKey.altRight) return 'Alt_R'; + if (key == LogicalKeyboardKey.metaLeft) return 'Super_L'; + if (key == LogicalKeyboardKey.metaRight) return 'Super_R'; + if (key == LogicalKeyboardKey.capsLock) return 'Caps_Lock'; final label = key.keyLabel; if (label.isEmpty) return null; @@ -123,6 +133,26 @@ const Map _xdotoolPunctuationNames = { '[': 'bracketleft', ']': 'bracketright', '\\': 'backslash', + '!': 'exclam', + '@': 'at', + '#': 'numbersign', + '\$': 'dollar', + '%': 'percent', + '^': 'asciicircum', + '&': 'ampersand', + '*': 'asterisk', + '(': 'parenleft', + ')': 'parenright', + '+': 'plus', + '{': 'braceleft', + '}': 'braceright', + '|': 'bar', + ':': 'colon', + '"': 'quotedbl', + '<': 'less', + '>': 'greater', + '?': 'question', + '~': 'asciitilde', }; Offset? desktopContentPosition( diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index 8151f21c..d43d8879 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -47,6 +47,7 @@ class _DesktopViewState extends State { ); bool _useGpu = false; + bool _adaptiveResolution = true; bool _showAdvancedOptions = false; bool _showControlPanel = true; String _connectionState = 'disconnected'; @@ -123,8 +124,19 @@ class _DesktopViewState extends State { await _client.disconnect(); } else { final display = _displayController.text.trim(); - final width = int.tryParse(_widthController.text) ?? 1280; - final height = int.tryParse(_heightController.text) ?? 720; + int width = int.tryParse(_widthController.text) ?? 1280; + int height = int.tryParse(_heightController.text) ?? 720; + + if (_adaptiveResolution) { + final viewportSize = _getViewportSize(); + if (viewportSize.width > 0 && viewportSize.height > 0) { + width = (viewportSize.width.toInt() ~/ 2) * 2; + height = (viewportSize.height.toInt() ~/ 2) * 2; + _widthController.text = width.toString(); + _heightController.text = height.toString(); + } + } + final fps = int.tryParse(_fpsController.text) ?? 30; final bitrate = int.tryParse(_bitrateController.text) ?? 2000; _remoteDesktopSize = Size(width.toDouble(), height.toDouble()); @@ -323,12 +335,25 @@ class _DesktopViewState extends State { ), ), ), + // Adaptive Resolution Toggle + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(appText('自适应分辨率', 'Adaptive Resolution')), + Switch( + value: _adaptiveResolution, + onChanged: _connectionState == 'disconnected' + ? (val) => setState(() => _adaptiveResolution = val) + : null, + ), + ], + ), // Resolution settings SizedBox( width: 90, child: TextField( controller: _widthController, - enabled: _connectionState == 'disconnected', + enabled: _connectionState == 'disconnected' && !_adaptiveResolution, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '宽度'), ), @@ -337,7 +362,7 @@ class _DesktopViewState extends State { width: 90, child: TextField( controller: _heightController, - enabled: _connectionState == 'disconnected', + enabled: _connectionState == 'disconnected' && !_adaptiveResolution, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '高度'), ), From f73dac34497c0fcddba32215eda919f902223517 Mon Sep 17 00:00:00 2001 From: Cowork 3P Date: Thu, 4 Jun 2026 03:48:29 +0000 Subject: [PATCH 5/7] refactor(skills): clean Path B, add retry + auto-refresh, fix silent failures - Remove Path B (direct WebSocket RPC), unify skills loading via ACP bridge sessionClient - Delete skillsStatusPayloadInternal fragile nested-key parsing - SkillsController: explicit error when offline (no more silent empty), auto-retry with 2s/4s backoff - Auto-refresh on gateway connect via ChangeNotifier listener - Gateway connect: concurrent Future.wait for independent controller refreshes - UI: retry button in skill picker empty/error states - Clean up skillsController from relayChildChangeInternal listeners --- lib/app/app_controller_desktop_gateway.dart | 19 ++++--- ...pp_controller_desktop_runtime_helpers.dart | 2 - .../assistant_page_composer_skill_picker.dart | 11 ++++ ...assistant_page_composer_state_helpers.dart | 1 + lib/runtime/gateway_runtime_api.dart | 54 +++++++++--------- lib/runtime/runtime_controllers_entities.dart | 55 ++++++++++++++++--- 6 files changed, 97 insertions(+), 45 deletions(-) diff --git a/lib/app/app_controller_desktop_gateway.dart b/lib/app/app_controller_desktop_gateway.dart index 13527efe..d8da9614 100644 --- a/lib/app/app_controller_desktop_gateway.dart +++ b/lib/app/app_controller_desktop_gateway.dart @@ -232,14 +232,17 @@ extension AppControllerDesktopGateway on AppController { await refreshGatewayHealth(); await refreshAgents(); await refreshSessions(); - await skillsControllerInternal.refresh( - agentId: agentsControllerInternal.selectedAgentId.isEmpty - ? null - : agentsControllerInternal.selectedAgentId, - ); - await modelsControllerInternal.refresh(); - await cronJobsControllerInternal.refresh(); - await devicesControllerInternal.refresh(quiet: true); + // Refresh independent controllers concurrently. + await Future.wait(>[ + skillsControllerInternal.refresh( + agentId: agentsControllerInternal.selectedAgentId.isEmpty + ? null + : agentsControllerInternal.selectedAgentId, + ), + modelsControllerInternal.refresh(), + cronJobsControllerInternal.refresh(), + devicesControllerInternal.refresh(quiet: true), + ]); await settingsControllerInternal.refreshDerivedState(); try { await refreshAcpCapabilitiesInternal( diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index e8d1a8f0..2fb1d551 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -677,7 +677,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { agentsControllerInternal.addListener(relayChildChangeInternal); sessionsControllerInternal.addListener(relayChildChangeInternal); chatControllerInternal.addListener(relayChildChangeInternal); - skillsControllerInternal.addListener(relayChildChangeInternal); modelsControllerInternal.addListener(relayChildChangeInternal); cronJobsControllerInternal.addListener(relayChildChangeInternal); devicesControllerInternal.addListener(relayChildChangeInternal); @@ -693,7 +692,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { agentsControllerInternal.removeListener(relayChildChangeInternal); sessionsControllerInternal.removeListener(relayChildChangeInternal); chatControllerInternal.removeListener(relayChildChangeInternal); - skillsControllerInternal.removeListener(relayChildChangeInternal); modelsControllerInternal.removeListener(relayChildChangeInternal); cronJobsControllerInternal.removeListener(relayChildChangeInternal); devicesControllerInternal.removeListener(relayChildChangeInternal); diff --git a/lib/features/assistant/assistant_page_composer_skill_picker.dart b/lib/features/assistant/assistant_page_composer_skill_picker.dart index 7d2d2e86..f9b7e0fb 100644 --- a/lib/features/assistant/assistant_page_composer_skill_picker.dart +++ b/lib/features/assistant/assistant_page_composer_skill_picker.dart @@ -78,6 +78,7 @@ class SkillPickerPopoverInternal extends StatelessWidget { required this.hasQuery, required this.onQueryChanged, required this.onToggleSkill, + this.onRetry, }); final double maxHeight; @@ -90,6 +91,7 @@ class SkillPickerPopoverInternal extends StatelessWidget { final bool hasQuery; final ValueChanged onQueryChanged; final ValueChanged onToggleSkill; + final VoidCallback? onRetry; @override Widget build(BuildContext context) { @@ -190,6 +192,15 @@ class SkillPickerPopoverInternal extends StatelessWidget { ), ), ], + if ((hasError || (!isLoading && !hasQuery)) && + onRetry != null) ...[ + const SizedBox(height: 12), + TextButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 16), + label: Text(appText('重试', 'Retry')), + ), + ], ], ), ), diff --git a/lib/features/assistant/assistant_page_composer_state_helpers.dart b/lib/features/assistant/assistant_page_composer_state_helpers.dart index 68e078d4..95c2bc85 100644 --- a/lib/features/assistant/assistant_page_composer_state_helpers.dart +++ b/lib/features/assistant/assistant_page_composer_state_helpers.dart @@ -98,6 +98,7 @@ Widget buildSkillPickerOverlayForInternal( hasQuery: state.skillPickerQueryInternal.trim().isNotEmpty, onQueryChanged: state.setSkillPickerQueryInternal, onToggleSkill: (skillKey) => state.widget.onToggleSkill(skillKey), + onRetry: () => state.widget.controller.skillsController.refresh(), ), ), ], diff --git a/lib/runtime/gateway_runtime_api.dart b/lib/runtime/gateway_runtime_api.dart index 8fc56fb8..6fb1fd05 100644 --- a/lib/runtime/gateway_runtime_api.dart +++ b/lib/runtime/gateway_runtime_api.dart @@ -203,18 +203,34 @@ extension GatewayRuntimeApiInternal on GatewayRuntime { if (agentId != null && agentId.trim().isNotEmpty) 'agentId': agentId.trim(), }; + if (sessionClientInternal == null) { + throw GatewayRuntimeException( + 'skills.status requires bridge session (ACP transport)', + code: 'BRIDGE_NOT_CONFIGURED', + ); + } final payload = asMap( - sessionClientInternal == null - ? await request('skills.status', params: params) - : await sessionClientInternal!.request( - runtimeId: runtimeIdInternal, - method: 'skills.status', - params: params, - allowErrorPayload: true, - ), + await sessionClientInternal!.request( + runtimeId: runtimeIdInternal, + method: 'skills.status', + params: params, + allowErrorPayload: true, + ), ); - final statusPayload = skillsStatusPayloadInternal(payload); - return asList(statusPayload['skills']) + final skillsList = asList(payload['skills']); + // When allowErrorPayload returns the payload from an ok:false envelope, + // the error info is in the payload itself rather than thrown as an exception. + if (skillsList.isEmpty) { + final errorMsg = + stringValue(payload['error']) ?? stringValue(payload['message']); + if (errorMsg != null && errorMsg.isNotEmpty) { + throw GatewayRuntimeException( + 'Failed to load skills: $errorMsg', + code: 'SKILLS_STATUS_ERROR', + ); + } + } + return skillsList .map((item) { final map = asMap(item); return GatewaySkillSummary( @@ -561,24 +577,6 @@ extension GatewayRuntimeApiInternal on GatewayRuntime { } } -Map skillsStatusPayloadInternal(Map payload) { - if (asList(payload['skills']).isNotEmpty) { - return payload; - } - for (final key in const [ - 'status', - 'skillStatus', - 'data', - 'payload', - ]) { - final nested = asMap(payload[key]); - if (asList(nested['skills']).isNotEmpty) { - return nested; - } - } - return payload; -} - List skillMissingListInternal( Map skill, String nestedKey, diff --git a/lib/runtime/runtime_controllers_entities.dart b/lib/runtime/runtime_controllers_entities.dart index 9e146943..439580ab 100644 --- a/lib/runtime/runtime_controllers_entities.dart +++ b/lib/runtime/runtime_controllers_entities.dart @@ -12,39 +12,80 @@ import 'runtime_controllers_gateway.dart'; import 'runtime_controllers_derived_tasks.dart'; class SkillsController extends ChangeNotifier { - SkillsController(this.runtimeInternal); + SkillsController(this.runtimeInternal) { + _runtimeListener = () { + if (runtimeInternal.isConnected && loadingInternal) { + // Gateway just connected while we were in a loading state + // that likely failed — auto-retry. + refresh(); + } else if (runtimeInternal.isConnected && itemsInternal.isEmpty) { + // Gateway connected for the first time and skills haven't been loaded. + refresh(); + } + }; + runtimeInternal.addListener(_runtimeListener!); + } final GatewayRuntime runtimeInternal; List itemsInternal = const []; bool loadingInternal = false; String? errorInternal; + int _retryCount = 0; + static const int _maxRetries = 2; + VoidCallback? _runtimeListener; List get items => itemsInternal; bool get loading => loadingInternal; String? get error => errorInternal; + /// Whether the user can manually retry (non-empty error + not loading). + bool get canRetry => + (errorInternal?.isNotEmpty ?? false) && !loadingInternal; + Future refresh({String? agentId}) async { if (!runtimeInternal.isConnected) { - if (!runtimeInternal.canConnectBridgeSession) { - itemsInternal = const []; - errorInternal = null; - notifyListeners(); - return; - } + errorInternal = 'Gateway 未连接,无法加载技能列表。'; + notifyListeners(); + return; } loadingInternal = true; errorInternal = null; + _retryCount = 0; notifyListeners(); + await _doRefresh(agentId: agentId); + } + + Future _doRefresh({String? agentId}) async { try { itemsInternal = await runtimeInternal.listSkills(agentId: agentId); + errorInternal = null; + _retryCount = 0; } catch (error) { + if (_retryCount < _maxRetries && runtimeInternal.isConnected) { + _retryCount++; + final delay = Duration(seconds: _retryCount * 2); + await Future.delayed(delay); + if (runtimeInternal.isConnected) { + await _doRefresh(agentId: agentId); + return; + } + } errorInternal = error.toString(); } finally { loadingInternal = false; notifyListeners(); } } + + @override + void dispose() { + if (_runtimeListener != null) { + runtimeInternal.removeListener(_runtimeListener!); + _runtimeListener = null; + } + super.dispose(); + } } class ModelsController extends ChangeNotifier { From 49bce7e166e61906f41522e2f8adc6ba9038bf19 Mon Sep 17 00:00:00 2001 From: Cowork 3P Date: Thu, 4 Jun 2026 04:26:03 +0000 Subject: [PATCH 6/7] refactor(skills): fix allowErrorPayload validation, improve auto-refresh guard --- .../assistant_page_composer_skill_picker.dart | 16 +++++++ lib/runtime/gateway_runtime_api.dart | 44 +++++++++++++++---- lib/runtime/runtime_controllers_entities.dart | 20 ++++++--- 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/lib/features/assistant/assistant_page_composer_skill_picker.dart b/lib/features/assistant/assistant_page_composer_skill_picker.dart index f9b7e0fb..6a170a60 100644 --- a/lib/features/assistant/assistant_page_composer_skill_picker.dart +++ b/lib/features/assistant/assistant_page_composer_skill_picker.dart @@ -192,6 +192,22 @@ class SkillPickerPopoverInternal extends StatelessWidget { ), ), ], + if (!hasError && !isLoading && !hasQuery) ...[ + const SizedBox(height: 8), + Text( + appText( + '技能来源于 Gateway 工作区。请确认 OpenClaw' + ' Gateway 已连接且安装了技能包。', + 'Skills come from the Gateway workspace.' + ' Make sure OpenClaw Gateway is connected' + ' and skills are installed.', + ), + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + ], if ((hasError || (!isLoading && !hasQuery)) && onRetry != null) ...[ const SizedBox(height: 12), diff --git a/lib/runtime/gateway_runtime_api.dart b/lib/runtime/gateway_runtime_api.dart index 6fb1fd05..673ec755 100644 --- a/lib/runtime/gateway_runtime_api.dart +++ b/lib/runtime/gateway_runtime_api.dart @@ -209,6 +209,8 @@ extension GatewayRuntimeApiInternal on GatewayRuntime { code: 'BRIDGE_NOT_CONFIGURED', ); } + // Use allowErrorPayload so the bridge can return cached skills even when + // the upstream OpenClaw gateway is temporarily offline (ok:false + payload). final payload = asMap( await sessionClientInternal!.request( runtimeId: runtimeIdInternal, @@ -218,17 +220,43 @@ extension GatewayRuntimeApiInternal on GatewayRuntime { ), ); final skillsList = asList(payload['skills']); - // When allowErrorPayload returns the payload from an ok:false envelope, - // the error info is in the payload itself rather than thrown as an exception. - if (skillsList.isEmpty) { - final errorMsg = - stringValue(payload['error']) ?? stringValue(payload['message']); - if (errorMsg != null && errorMsg.isNotEmpty) { + // When the skills key is entirely absent (not just an empty list), the + // gateway may have returned a stub or error payload. Distinguish between + // "genuinely no skills" and "gateway responded without skills data". + if (!payload.containsKey('skills')) { + final hasWorkspaceMeta = payload.containsKey('workspaceDir') || + payload.containsKey('managedSkillsDir'); + if (!hasWorkspaceMeta) { + appendLogInternal( + this, + 'warn', + 'skills', + 'skills.status returned payload without skills key and without' + ' workspace metadata — likely a gateway error or unimplemented method', + ); throw GatewayRuntimeException( - 'Failed to load skills: $errorMsg', - code: 'SKILLS_STATUS_ERROR', + 'OpenClaw gateway did not return skills data.' + ' The gateway may not have skills.status implemented.', + code: 'SKILLS_STATUS_MISSING', ); } + // Gateway responded with workspace metadata but no skills key — + // genuinely no skills installed. Return empty list. + appendLogInternal( + this, + 'debug', + 'skills', + 'skills.status returned workspace metadata with zero skills', + ); + return const []; + } + if (skillsList.isEmpty) { + appendLogInternal( + this, + 'debug', + 'skills', + 'skills.status returned empty skills list (${payload.length} payload keys)', + ); } return skillsList .map((item) { diff --git a/lib/runtime/runtime_controllers_entities.dart b/lib/runtime/runtime_controllers_entities.dart index 439580ab..8d15561b 100644 --- a/lib/runtime/runtime_controllers_entities.dart +++ b/lib/runtime/runtime_controllers_entities.dart @@ -14,14 +14,19 @@ import 'runtime_controllers_derived_tasks.dart'; class SkillsController extends ChangeNotifier { SkillsController(this.runtimeInternal) { _runtimeListener = () { - if (runtimeInternal.isConnected && loadingInternal) { - // Gateway just connected while we were in a loading state - // that likely failed — auto-retry. - refresh(); - } else if (runtimeInternal.isConnected && itemsInternal.isEmpty) { - // Gateway connected for the first time and skills haven't been loaded. - refresh(); + if (!runtimeInternal.isConnected) { + // Reset auto-refresh flag on disconnect so a subsequent reconnect + // will trigger a fresh load. + _hasAutoRefreshed = false; + return; } + // Auto-refresh on first gateway connect only when skills are empty, + // not already loading, and haven't auto-refreshed this session. + if (loadingInternal || itemsInternal.isNotEmpty || _hasAutoRefreshed) { + return; + } + _hasAutoRefreshed = true; + refresh(); }; runtimeInternal.addListener(_runtimeListener!); } @@ -34,6 +39,7 @@ class SkillsController extends ChangeNotifier { int _retryCount = 0; static const int _maxRetries = 2; VoidCallback? _runtimeListener; + bool _hasAutoRefreshed = false; List get items => itemsInternal; bool get loading => loadingInternal; From f50e5c4d0e7ada8e7f9abd5a387aadcaafc7f042 Mon Sep 17 00:00:00 2001 From: Cowork 3P Date: Thu, 4 Jun 2026 12:57:43 +0800 Subject: [PATCH 7/7] feat(ui): apply BoxFit.fill for remote desktop WebRTC view to ensure no blank spaces --- .../desktop/desktop_input_handler.dart | 35 +++---------------- lib/features/desktop/desktop_view.dart | 16 ++++++--- 2 files changed, 16 insertions(+), 35 deletions(-) diff --git a/lib/features/desktop/desktop_input_handler.dart b/lib/features/desktop/desktop_input_handler.dart index cce5536b..c5f3f6f6 100644 --- a/lib/features/desktop/desktop_input_handler.dart +++ b/lib/features/desktop/desktop_input_handler.dart @@ -162,37 +162,10 @@ Offset? desktopContentPosition( }) { if (viewportSize.width <= 0 || viewportSize.height <= 0) return null; - final resolvedContentSize = contentSize; - if (resolvedContentSize == null || - resolvedContentSize.width <= 0 || - resolvedContentSize.height <= 0) { - return Offset( - (localPosition.dx / viewportSize.width).clamp(0.0, 1.0), - (localPosition.dy / viewportSize.height).clamp(0.0, 1.0), - ); - } - - final viewportAspect = viewportSize.width / viewportSize.height; - final contentAspect = resolvedContentSize.width / resolvedContentSize.height; - - double drawnWidth; - double drawnHeight; - double offsetX; - double offsetY; - if (viewportAspect > contentAspect) { - drawnHeight = viewportSize.height; - drawnWidth = drawnHeight * contentAspect; - offsetX = (viewportSize.width - drawnWidth) / 2; - offsetY = 0; - } else { - drawnWidth = viewportSize.width; - drawnHeight = drawnWidth / contentAspect; - offsetX = 0; - offsetY = (viewportSize.height - drawnHeight) / 2; - } - + // We are using FittedBox(fit: BoxFit.fill) which stretches the video + // to fill the viewport exactly, without any padding/offsets. return Offset( - ((localPosition.dx - offsetX) / drawnWidth).clamp(0.0, 1.0), - ((localPosition.dy - offsetY) / drawnHeight).clamp(0.0, 1.0), + (localPosition.dx / viewportSize.width).clamp(0.0, 1.0), + (localPosition.dy / viewportSize.height).clamp(0.0, 1.0), ); } diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index d43d8879..d03adb7e 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -509,10 +509,18 @@ class _DesktopViewState extends State { _inputHandler!.handleScroll(event); } }, - child: RTCVideoView( - _localRenderer, - objectFit: RTCVideoViewObjectFit - .RTCVideoViewObjectFitContain, + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.fill, + child: SizedBox( + width: _remoteDesktopSize.width > 0 ? _remoteDesktopSize.width : 1280, + height: _remoteDesktopSize.height > 0 ? _remoteDesktopSize.height : 720, + child: RTCVideoView( + _localRenderer, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, + ), + ), + ), ), ), ),