diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 82c0a407..8964ad67 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -959,7 +959,8 @@ class AppController extends ChangeNotifier { threadId: sessionKey, prompt: composedPrompt, workingDirectory: - _resolveCodexWorkingDirectory() ?? Directory.current.path, + _assistantWorkingDirectoryForSession(sessionKey) ?? + Directory.current.path, attachments: attachments, selectedSkills: selectedSkillLabels, aiGatewayBaseUrl: aiGatewayUrl, @@ -1301,8 +1302,14 @@ class AppController extends ChangeNotifier { resolvedTarget, ); final existing = _assistantThreadRecords[normalizedSessionKey]; + final existingWorkspaceRef = existing?.workspaceRef.trim() ?? ''; if (existing != null && - existing.workspaceRef == nextWorkspaceRef && + existingWorkspaceRef.isNotEmpty && + existing.workspaceRefKind == nextWorkspaceRefKind) { + return; + } + if (existing != null && + existingWorkspaceRef == nextWorkspaceRef && existing.workspaceRefKind == nextWorkspaceRefKind) { return; } @@ -1880,8 +1887,10 @@ class AppController extends ChangeNotifier { executionTarget: nextTarget, messageViewMode: nextViewMode, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - workspaceRef: _defaultWorkspaceRefForSession(nextSessionKey), - workspaceRefKind: _defaultWorkspaceRefKindForTarget(nextTarget), + ); + _syncAssistantWorkspaceRefForSession( + nextSessionKey, + executionTarget: nextTarget, ); await _applyAssistantExecutionTarget( nextTarget, @@ -1981,12 +1990,10 @@ class AppController extends ChangeNotifier { _sessionsController.currentSessionKey, executionTarget: resolvedTarget, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - workspaceRef: switch (resolvedTarget) { - AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(), - AssistantExecutionTarget.local || - AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(), - }, - workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget), + ); + _syncAssistantWorkspaceRefForSession( + _sessionsController.currentSessionKey, + executionTarget: resolvedTarget, ); _recomputeTasks(); _notifyIfActive(); @@ -3588,7 +3595,8 @@ class AppController extends ChangeNotifier { model: assistantModelForSession(sessionKey), gatewayToken: gatewayToken, workingDirectory: - _resolveCodexWorkingDirectory() ?? Directory.current.path, + _resolveLocalAssistantWorkingDirectoryForSession(sessionKey) ?? + Directory.current.path, attachments: localAttachments, selectedSkills: selectedSkills, aiGatewayBaseUrl: aiGatewayUrl, @@ -5299,11 +5307,23 @@ class AppController extends ChangeNotifier { .toList(growable: false); } - String? _resolveCodexWorkingDirectory() { - final candidate = settings.workspacePath.trim(); + String? _assistantWorkingDirectoryForSession(String sessionKey) { + final candidate = assistantWorkspaceRefForSession(sessionKey).trim(); if (candidate.isEmpty) { return null; } + return candidate; + } + + String? _resolveLocalAssistantWorkingDirectoryForSession(String sessionKey) { + if (assistantWorkspaceRefKindForSession(sessionKey) != + WorkspaceRefKind.localPath) { + return null; + } + final candidate = _assistantWorkingDirectoryForSession(sessionKey); + if (candidate == null) { + return null; + } final directory = Directory(candidate); return directory.existsSync() ? directory.path : null; } diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 81f1272d..d2a5cac3 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -35,6 +35,7 @@ const double _assistantVerticalResizeHandleHeight = 10; const double _assistantArtifactPaneMinWidth = 280; const double _assistantArtifactPaneDefaultWidth = 360; const double _assistantCollapsedArtifactToggleClearance = 56; +const double _assistantComposerSafeAreaGap = 8; typedef AssistantClipboardImageReader = Future Function(); @@ -396,6 +397,14 @@ class _AssistantPageState extends State { }) { return LayoutBuilder( builder: (context, constraints) { + final mediaQuery = MediaQuery.of(context); + final composerBottomInset = math.max( + mediaQuery.viewPadding.bottom, + mediaQuery.viewInsets.bottom, + ); + final composerBottomSpacing = composerBottomInset > 0 + ? composerBottomInset + _assistantComposerSafeAreaGap + : _assistantComposerSafeAreaGap; final baseComposerHeight = constraints.maxHeight >= 900 ? 180.0 : 152.0; final composerContentWidth = math.max(240.0, constraints.maxWidth - 32); final availableWorkspaceHeight = math.max( @@ -420,17 +429,18 @@ class _AssistantPageState extends State { _composerInputHeight - _assistantComposerDefaultInputHeight, ) + attachmentExtraHeight + - selectedSkillExtraHeight, + selectedSkillExtraHeight + + composerBottomSpacing, ); final composerHeightUpperBound = math.min( availableWorkspaceHeight, math.max( - _assistantWorkspaceMinLowerPaneHeight, + _assistantWorkspaceMinLowerPaneHeight + composerBottomSpacing, availableWorkspaceHeight - _assistantWorkspaceMinConversationHeight, ), ); final composerHeightLowerBound = math.min( - _assistantWorkspaceMinLowerPaneHeight, + _assistantWorkspaceMinLowerPaneHeight + composerBottomSpacing, composerHeightUpperBound, ); final composerHeight = @@ -448,6 +458,7 @@ class _AssistantPageState extends State { currentTask: currentTask, items: timelineItems, messageViewMode: controller.currentAssistantMessageViewMode, + bottomContentInset: composerBottomSpacing, topTrailingInset: _artifactPaneCollapsed ? _assistantCollapsedArtifactToggleClearance : 0, @@ -485,6 +496,7 @@ class _AssistantPageState extends State { key: const Key('assistant-composer-shell'), height: composerHeight, child: _AssistantLowerPane( + bottomContentInset: composerBottomSpacing, inputController: _inputController, focusNode: _composerFocusNode, thinkingLabel: _thinkingLabel, @@ -790,7 +802,11 @@ class _AssistantPageState extends State { await controller.selectAgent(autoAgent.id); } - final attachmentNames = _attachments + final submittedAttachments = List<_ComposerAttachment>.from( + _attachments, + growable: false, + ); + final attachmentNames = submittedAttachments .map((item) => item.name) .toList(growable: false); final selectedSkillLabels = _resolveSelectedSkillLabels(controller); @@ -813,6 +829,7 @@ class _AssistantPageState extends State { _lastAutoAgentLabel = autoAgent?.name ?? _conversationOwnerLabel(controller); _lastSubmittedAttachments = attachmentNames; + _attachments = const <_ComposerAttachment>[]; _touchTaskSeed( sessionKey: controller.currentSessionKey, title: @@ -831,31 +848,12 @@ class _AssistantPageState extends State { draft: controller.currentSessionKey.trim().startsWith('draft:'), ); }); + _inputController.clear(); - if (uiFeatures.supportsMultiAgent && - controller.settings.multiAgent.enabled) { - final collaborationAttachments = _attachments - .map( - (item) => CollaborationAttachment( - name: item.name, - description: item.mimeType, - path: item.path, - ), - ) - .toList(growable: false); - await controller.runMultiAgentCollaboration( - rawPrompt: rawPrompt, - composedPrompt: prompt, - attachments: collaborationAttachments, - selectedSkillLabels: selectedSkillLabels, - ); - } else { - final attachmentPayloads = await _buildAttachmentPayloads(_attachments); - await controller.sendChatMessage( - prompt, - thinking: _thinkingLabel, - attachments: attachmentPayloads, - localAttachments: _attachments + try { + if (uiFeatures.supportsMultiAgent && + controller.settings.multiAgent.enabled) { + final collaborationAttachments = submittedAttachments .map( (item) => CollaborationAttachment( name: item.name, @@ -863,18 +861,50 @@ class _AssistantPageState extends State { path: item.path, ), ) - .toList(growable: false), - selectedSkillLabels: selectedSkillLabels, - ); + .toList(growable: false); + await controller.runMultiAgentCollaboration( + rawPrompt: rawPrompt, + composedPrompt: prompt, + attachments: collaborationAttachments, + selectedSkillLabels: selectedSkillLabels, + ); + } else { + final attachmentPayloads = await _buildAttachmentPayloads( + submittedAttachments, + ); + await controller.sendChatMessage( + prompt, + thinking: _thinkingLabel, + attachments: attachmentPayloads, + localAttachments: submittedAttachments + .map( + (item) => CollaborationAttachment( + name: item.name, + description: item.mimeType, + path: item.path, + ), + ) + .toList(growable: false), + selectedSkillLabels: selectedSkillLabels, + ); + } + } catch (_) { + if (!mounted) { + rethrow; + } + if (_inputController.text.trim().isEmpty) { + _inputController.value = TextEditingValue( + text: rawPrompt, + selection: TextSelection.collapsed(offset: rawPrompt.length), + ); + } + if (_attachments.isEmpty && submittedAttachments.isNotEmpty) { + setState(() { + _attachments = submittedAttachments; + }); + } + rethrow; } - - if (!mounted) { - return; - } - setState(() { - _attachments = const <_ComposerAttachment>[]; - }); - _inputController.clear(); } Future> _buildAttachmentPayloads( @@ -1736,6 +1766,7 @@ class _AssistantSideTabButtonState extends State<_AssistantSideTabButton> { class _AssistantLowerPane extends StatelessWidget { const _AssistantLowerPane({ + required this.bottomContentInset, required this.controller, required this.inputController, required this.focusNode, @@ -1760,6 +1791,7 @@ class _AssistantLowerPane extends StatelessWidget { required this.onSend, }); + final double bottomContentInset; final AppController controller; final TextEditingController inputController; final FocusNode focusNode; @@ -1789,29 +1821,32 @@ class _AssistantLowerPane extends StatelessWidget { alignment: Alignment.bottomCenter, child: SingleChildScrollView( physics: const ClampingScrollPhysics(), - child: _ComposerBar( - controller: controller, - inputController: inputController, - focusNode: focusNode, - thinkingLabel: thinkingLabel, - showModelControl: showModelControl, - modelLabel: modelLabel, - modelOptions: modelOptions, - attachments: attachments, - availableSkills: availableSkills, - selectedSkillKeys: selectedSkillKeys, - onRemoveAttachment: onRemoveAttachment, - onToggleSkill: onToggleSkill, - onThinkingChanged: onThinkingChanged, - onModelChanged: onModelChanged, - onOpenGateway: onOpenGateway, - onOpenAiGatewaySettings: onOpenAiGatewaySettings, - onReconnectGateway: onReconnectGateway, - onPickAttachments: onPickAttachments, - onAddAttachment: onAddAttachment, - onPasteImageAttachment: onPasteImageAttachment, - onInputHeightChanged: onComposerInputHeightChanged, - onSend: onSend, + child: Padding( + padding: EdgeInsets.only(bottom: bottomContentInset), + child: _ComposerBar( + controller: controller, + inputController: inputController, + focusNode: focusNode, + thinkingLabel: thinkingLabel, + showModelControl: showModelControl, + modelLabel: modelLabel, + modelOptions: modelOptions, + attachments: attachments, + availableSkills: availableSkills, + selectedSkillKeys: selectedSkillKeys, + onRemoveAttachment: onRemoveAttachment, + onToggleSkill: onToggleSkill, + onThinkingChanged: onThinkingChanged, + onModelChanged: onModelChanged, + onOpenGateway: onOpenGateway, + onOpenAiGatewaySettings: onOpenAiGatewaySettings, + onReconnectGateway: onReconnectGateway, + onPickAttachments: onPickAttachments, + onAddAttachment: onAddAttachment, + onPasteImageAttachment: onPasteImageAttachment, + onInputHeightChanged: onComposerInputHeightChanged, + onSend: onSend, + ), ), ), ); @@ -1824,6 +1859,7 @@ class _ConversationArea extends StatelessWidget { required this.currentTask, required this.items, required this.messageViewMode, + required this.bottomContentInset, required this.topTrailingInset, required this.scrollController, required this.onOpenDetail, @@ -1838,6 +1874,7 @@ class _ConversationArea extends StatelessWidget { final _AssistantTaskEntry currentTask; final List<_TimelineItem> items; final AssistantMessageViewMode messageViewMode; + final double bottomContentInset; final double topTrailingInset; final ScrollController scrollController; final ValueChanged onOpenDetail; @@ -1892,7 +1929,12 @@ class _ConversationArea extends StatelessWidget { ) : ListView.separated( controller: scrollController, - padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + padding: EdgeInsets.fromLTRB( + 10, + 8, + 10, + 8 + bottomContentInset, + ), physics: const BouncingScrollPhysics(), itemCount: items.length, separatorBuilder: (_, _) => const SizedBox(height: 6), diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 5cda36a4..e98b0b21 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -914,8 +914,8 @@ class _SettingsPageState extends State { const SizedBox(height: 8), Text( appText( - '这里仅保留 Codex、OpenCode 预设接入。历史上的 Claude / Gemini 预配置会迁移为自定义 ACP Server Endpoint。你可以继续添加多个自定义 Endpoint,协议支持 ws / wss / http / https。', - 'Only Codex and OpenCode stay as preset integrations here. Legacy Claude and Gemini entries are migrated into custom ACP server endpoints. You can add multiple custom endpoints with ws / wss / http / https.', + '这里保留 Codex、OpenCode 作为内建接入。更多 Provider 请通过向导新增自定义 ACP Server Endpoint;历史上真正配置过的 Claude / Gemini 会迁移为自定义条目,空白旧预设会自动清理。', + 'Codex and OpenCode stay here as built-in integrations. Add more providers through the custom ACP endpoint wizard; configured legacy Claude and Gemini entries are migrated into custom entries, while empty legacy presets are cleaned up automatically.', ), style: theme.textTheme.bodyMedium, ), @@ -924,16 +924,14 @@ class _SettingsPageState extends State { alignment: Alignment.centerLeft, child: FilledButton.tonalIcon( key: const ValueKey('external-acp-provider-add-button'), - onPressed: () => _saveSettings( + onPressed: () => _showAddExternalAcpProviderWizard( + context, controller, - _appendExternalAcpProvider(settings), + settings, ), icon: const Icon(Icons.add_rounded), label: Text( - appText( - '添加自定义 ACP Server Endpoint', - 'Add custom ACP server endpoint', - ), + appText('添加更多自定义配置', 'Add more custom configurations'), ), ), ), @@ -1040,30 +1038,147 @@ class _SettingsPageState extends State { ); } - SettingsSnapshot _appendExternalAcpProvider(SettingsSnapshot settings) { - var suffix = settings.externalAcpEndpoints.length + 1; - String providerKey() => 'custom-agent-$suffix'; - final existingKeys = settings.externalAcpEndpoints - .map((item) => item.providerKey) - .toSet(); - while (existingKeys.contains(providerKey())) { - suffix += 1; - } - return settings.copyWith( - externalAcpEndpoints: [ - ...settings.externalAcpEndpoints, - ExternalAcpEndpointProfile( - providerKey: providerKey(), - label: appText( - '自定义 ACP Endpoint $suffix', - 'Custom ACP Endpoint $suffix', - ), - badge: '', - endpoint: '', - enabled: true, + Future _showAddExternalAcpProviderWizard( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + ) async { + final nameController = TextEditingController(); + final endpointController = TextEditingController(); + var attemptedSubmit = false; + try { + final profile = await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + final name = nameController.text.trim(); + final endpoint = endpointController.text.trim(); + final endpointValid = + endpoint.isEmpty || isSupportedExternalAcpEndpoint(endpoint); + final canSubmit = + name.isNotEmpty && endpoint.isNotEmpty && endpointValid; + return AlertDialog( + title: Text( + appText('添加自定义 ACP Endpoint', 'Add custom ACP endpoint'), + ), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText( + '通过向导新增更多外部 Agent Provider。先填写显示名称,再输入可访问的 ACP Server Endpoint。', + 'Use this wizard to add more external agent providers. Start with a display name, then enter a reachable ACP server endpoint.', + ), + ), + const SizedBox(height: 16), + Text( + appText('步骤 1 · 显示名称', 'Step 1 · Display name'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + TextField( + key: const ValueKey('external-acp-wizard-name-field'), + controller: nameController, + autofocus: true, + decoration: InputDecoration( + hintText: appText( + '例如:Claude Sonnet / Lab Agent', + 'For example: Claude Sonnet / Lab Agent', + ), + ), + onChanged: (_) => setDialogState(() {}), + ), + const SizedBox(height: 16), + Text( + appText( + '步骤 2 · ACP Server Endpoint', + 'Step 2 · ACP Server Endpoint', + ), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + TextField( + key: const ValueKey( + 'external-acp-wizard-endpoint-field', + ), + controller: endpointController, + decoration: InputDecoration( + hintText: 'ws://127.0.0.1:9001', + errorText: attemptedSubmit && endpoint.isEmpty + ? appText( + '请输入 ACP Server Endpoint。', + 'Enter an ACP server endpoint.', + ) + : attemptedSubmit && !endpointValid + ? appText( + '仅支持 ws / wss / http / https。', + 'Only ws / wss / http / https are supported.', + ) + : null, + ), + onChanged: (_) => setDialogState(() {}), + ), + const SizedBox(height: 8), + Text( + appText( + '支持协议:ws、wss、http、https。新增后会出现在下方列表,并和助手页的 provider 菜单保持一致。', + 'Supported schemes: ws, wss, http, https. The new entry appears in the list below and stays aligned with the assistant provider menu.', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + key: const ValueKey('external-acp-wizard-confirm-button'), + onPressed: canSubmit + ? () { + Navigator.of(dialogContext).pop( + buildCustomExternalAcpEndpointProfile( + settings.externalAcpEndpoints, + label: name, + endpoint: endpoint, + ), + ); + } + : () { + setDialogState(() { + attemptedSubmit = true; + }); + }, + child: Text(appText('添加', 'Add')), + ), + ], + ); + }, + ); + }, + ); + if (profile == null) { + return; + } + await _saveSettings( + controller, + settings.copyWith( + externalAcpEndpoints: [ + ...settings.externalAcpEndpoints, + profile, + ], ), - ], - ); + ); + } finally { + nameController.dispose(); + endpointController.dispose(); + } } Widget _buildLlmEndpointManager( diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 24d5071d..59150ff7 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -134,6 +134,23 @@ String _singleAgentProviderFallbackBadge({ return stripped.substring(0, length).toUpperCase(); } +const Set kSupportedExternalAcpEndpointSchemes = { + 'ws', + 'wss', + 'http', + 'https', +}; + +bool isSupportedExternalAcpEndpoint(String endpoint) { + final trimmed = endpoint.trim(); + if (trimmed.isEmpty) { + return false; + } + final uri = Uri.tryParse(trimmed); + final scheme = uri?.scheme.trim().toLowerCase() ?? ''; + return kSupportedExternalAcpEndpointSchemes.contains(scheme); +} + class SingleAgentProvider { const SingleAgentProvider({ required this.providerId, @@ -391,6 +408,9 @@ List normalizeExternalAcpEndpoints({ continue; } if (kLegacyExternalAcpProviderIds.contains(key)) { + if (item.endpoint.trim().isEmpty) { + continue; + } migratedCustomProfiles.add(item.copyWith(providerKey: nextCustomKey())); continue; } @@ -430,6 +450,38 @@ List replaceExternalAcpEndpointForProvider( return normalizeExternalAcpEndpoints(profiles: next); } +ExternalAcpEndpointProfile buildCustomExternalAcpEndpointProfile( + Iterable profiles, { + required String label, + required String endpoint, +}) { + final normalizedProfiles = normalizeExternalAcpEndpoints(profiles: profiles); + var suffix = normalizedProfiles.length + 1; + + String providerKey() => 'custom-agent-$suffix'; + + final existingKeys = normalizedProfiles + .map((item) => item.providerKey) + .toSet(); + while (existingKeys.contains(providerKey())) { + suffix += 1; + } + + final normalizedLabel = label.trim().isEmpty + ? 'Custom ACP Endpoint $suffix' + : label.trim(); + return ExternalAcpEndpointProfile( + providerKey: providerKey(), + label: normalizedLabel, + badge: _singleAgentProviderFallbackBadge( + providerId: providerKey(), + label: normalizedLabel, + ), + endpoint: endpoint.trim(), + enabled: true, + ); +} + String normalizeAuthorizedSkillDirectoryPath(String path) { var trimmed = path.trim(); if (trimmed.isEmpty) { diff --git a/lib/web/web_settings_page.dart b/lib/web/web_settings_page.dart index 4b653796..54a74baf 100644 --- a/lib/web/web_settings_page.dart +++ b/lib/web/web_settings_page.dart @@ -926,8 +926,8 @@ class _WebSettingsPageState extends State { const SizedBox(height: 8), Text( appText( - '这里仅保留 Codex、OpenCode 预设接入。历史上的 Claude / Gemini 预配置会迁移为自定义 ACP Server Endpoint。你可以继续添加多个自定义 Endpoint,协议支持 ws / wss / http / https。', - 'Only Codex and OpenCode stay as preset integrations here. Legacy Claude and Gemini entries are migrated into custom ACP server endpoints. You can add multiple custom endpoints with ws / wss / http / https.', + '这里保留 Codex、OpenCode 作为内建接入。更多 Provider 请通过向导新增自定义 ACP Server Endpoint;历史上真正配置过的 Claude / Gemini 会迁移为自定义条目,空白旧预设会自动清理。', + 'Codex and OpenCode stay here as built-in integrations. Add more providers through the custom ACP endpoint wizard; configured legacy Claude and Gemini entries are migrated into custom entries, while empty legacy presets are cleaned up automatically.', ), style: theme.textTheme.bodyMedium, ), @@ -936,19 +936,11 @@ class _WebSettingsPageState extends State { alignment: Alignment.centerLeft, child: FilledButton.tonalIcon( key: const ValueKey('web-external-acp-provider-add-button'), - onPressed: () { - unawaited( - controller.saveSettingsDraft( - _appendExternalAcpProvider(controller.settingsDraft), - ), - ); - }, + onPressed: () => + _showAddExternalAcpProviderWizard(context, controller), icon: const Icon(Icons.add_rounded), label: Text( - appText( - '添加自定义 ACP Server Endpoint', - 'Add custom ACP server endpoint', - ), + appText('添加更多自定义配置', 'Add more custom configurations'), ), ), ), @@ -1083,30 +1075,150 @@ class _WebSettingsPageState extends State { unawaited(controller.saveSettingsDraft(next)); } - SettingsSnapshot _appendExternalAcpProvider(SettingsSnapshot settings) { - var suffix = settings.externalAcpEndpoints.length + 1; - String providerKey() => 'custom-agent-$suffix'; - final existingKeys = settings.externalAcpEndpoints - .map((item) => item.providerKey) - .toSet(); - while (existingKeys.contains(providerKey())) { - suffix += 1; - } - return settings.copyWith( - externalAcpEndpoints: [ - ...settings.externalAcpEndpoints, - ExternalAcpEndpointProfile( - providerKey: providerKey(), - label: appText( - '自定义 ACP Endpoint $suffix', - 'Custom ACP Endpoint $suffix', - ), - badge: '$suffix', - endpoint: '', - enabled: true, + Future _showAddExternalAcpProviderWizard( + BuildContext context, + AppController controller, + ) async { + final settings = controller.settingsDraft; + final nameController = TextEditingController(); + final endpointController = TextEditingController(); + var attemptedSubmit = false; + try { + final profile = await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + final name = nameController.text.trim(); + final endpoint = endpointController.text.trim(); + final endpointValid = + endpoint.isEmpty || isSupportedExternalAcpEndpoint(endpoint); + final canSubmit = + name.isNotEmpty && endpoint.isNotEmpty && endpointValid; + return AlertDialog( + title: Text( + appText('添加自定义 ACP Endpoint', 'Add custom ACP endpoint'), + ), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText( + '通过向导新增更多外部 Agent Provider。先填写显示名称,再输入可访问的 ACP Server Endpoint。', + 'Use this wizard to add more external agent providers. Start with a display name, then enter a reachable ACP server endpoint.', + ), + ), + const SizedBox(height: 16), + Text( + appText('步骤 1 · 显示名称', 'Step 1 · Display name'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + TextField( + key: const ValueKey( + 'web-external-acp-wizard-name-field', + ), + controller: nameController, + autofocus: true, + decoration: InputDecoration( + hintText: appText( + '例如:Claude Sonnet / Lab Agent', + 'For example: Claude Sonnet / Lab Agent', + ), + ), + onChanged: (_) => setDialogState(() {}), + ), + const SizedBox(height: 16), + Text( + appText( + '步骤 2 · ACP Server Endpoint', + 'Step 2 · ACP Server Endpoint', + ), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + TextField( + key: const ValueKey( + 'web-external-acp-wizard-endpoint-field', + ), + controller: endpointController, + decoration: InputDecoration( + hintText: 'ws://127.0.0.1:9001', + errorText: attemptedSubmit && endpoint.isEmpty + ? appText( + '请输入 ACP Server Endpoint。', + 'Enter an ACP server endpoint.', + ) + : attemptedSubmit && !endpointValid + ? appText( + '仅支持 ws / wss / http / https。', + 'Only ws / wss / http / https are supported.', + ) + : null, + ), + onChanged: (_) => setDialogState(() {}), + ), + const SizedBox(height: 8), + Text( + appText( + '支持协议:ws、wss、http、https。新增后会出现在下方列表,并和助手页的 provider 菜单保持一致。', + 'Supported schemes: ws, wss, http, https. The new entry appears in the list below and stays aligned with the assistant provider menu.', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + key: const ValueKey( + 'web-external-acp-wizard-confirm-button', + ), + onPressed: canSubmit + ? () { + Navigator.of(dialogContext).pop( + buildCustomExternalAcpEndpointProfile( + settings.externalAcpEndpoints, + label: name, + endpoint: endpoint, + ), + ); + } + : () { + setDialogState(() { + attemptedSubmit = true; + }); + }, + child: Text(appText('添加', 'Add')), + ), + ], + ); + }, + ); + }, + ); + if (profile == null) { + return; + } + await controller.saveSettingsDraft( + settings.copyWith( + externalAcpEndpoints: [ + ...settings.externalAcpEndpoints, + profile, + ], ), - ], - ); + ); + } finally { + nameController.dispose(); + endpointController.dispose(); + } } Widget _buildGatewayCard( diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index 6a388e6a..9689f221 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -1,6 +1,7 @@ @TestOn('vm') library; +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -15,6 +16,7 @@ import 'package:xworkmate/models/app_models.dart'; import 'package:xworkmate/runtime/codex_runtime.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; +import 'package:xworkmate/runtime/multi_agent_orchestrator.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; @@ -494,6 +496,52 @@ void main() { }, ); + testWidgets( + 'AssistantPage keeps composer controls above the safe bottom inset', + (WidgetTester tester) async { + final controller = await createTestController(tester); + const safeBottomInset = 36.0; + + await pumpPage( + tester, + child: Builder( + builder: (context) { + final mediaQuery = MediaQuery.of(context); + return MediaQuery( + data: mediaQuery.copyWith( + padding: mediaQuery.padding.copyWith(bottom: safeBottomInset), + viewPadding: mediaQuery.viewPadding.copyWith( + bottom: safeBottomInset, + ), + ), + child: AssistantPage( + controller: controller, + onOpenDetail: (_) {}, + ), + ); + }, + ), + ); + + final pageRect = tester.getRect(find.byType(AssistantPage)); + final composerShell = find.byKey(const Key('assistant-composer-shell')); + final submitButton = find.byKey(const Key('assistant-submit-button')); + + expect(composerShell, findsOneWidget); + expect(submitButton, findsOneWidget); + expect( + tester.getRect(composerShell).bottom, + moreOrLessEquals(pageRect.bottom, epsilon: 0.01), + ); + expect( + tester.getRect(submitButton).bottom, + lessThanOrEqualTo( + tester.getRect(composerShell).bottom - safeBottomInset, + ), + ); + }, + ); + testWidgets('AssistantPage keeps a minimal composer action menu', ( WidgetTester tester, ) async { @@ -550,6 +598,58 @@ void main() { expect(find.text('远程 OpenClaw Gateway'), findsWidgets); }); + testWidgets( + 'AssistantPage clears submitted composer text before send completes', + (WidgetTester tester) async { + late final _PendingSendAppController controller; + final sendGate = Completer(); + await tester.runAsync(() async { + SharedPreferences.setMockInitialValues({}); + final store = createIsolatedTestStore(enableSecureStorage: false); + controller = _PendingSendAppController( + store: store, + sendGate: sendGate, + ); + final stopwatch = Stopwatch()..start(); + while (controller.initializing) { + if (stopwatch.elapsed > const Duration(seconds: 10)) { + fail('controller did not finish initializing before timeout'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + addTearDown(() async { + if (!sendGate.isCompleted) { + sendGate.complete(); + } + }); + addTearDown(controller.dispose); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + platform: TargetPlatform.macOS, + ); + + final composerInput = find.descendant( + of: find.byKey(const Key('assistant-composer-input-area')), + matching: find.byType(TextField), + ); + expect(composerInput, findsOneWidget); + + await tester.enterText(composerInput, '分析一下这个 bug'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(controller.sendCallCount, 1); + expect(controller.lastSentMessage, isNotEmpty); + expect(tester.widget(composerInput).controller?.text, isEmpty); + + sendGate.complete(); + await tester.pumpAndSettle(); + }, + ); + testWidgets( 'AssistantPage shows a persistent skill popover in single-agent mode and keeps thread selections isolated', (WidgetTester tester) async { @@ -1341,6 +1441,38 @@ Future _waitForCondition(bool Function() predicate) async { } } +class _PendingSendAppController extends AppController { + _PendingSendAppController({ + required SecureConfigStore store, + required this.sendGate, + }) : super( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ), + ); + + final Completer sendGate; + int sendCallCount = 0; + String lastSentMessage = ''; + + @override + Future sendChatMessage( + String message, { + String thinking = 'off', + List attachments = + const [], + List localAttachments = + const [], + List selectedSkillLabels = const [], + }) async { + sendCallCount += 1; + lastSentMessage = message; + await sendGate.future; + } +} + class _FakeGatewayRuntime extends GatewayRuntime { _FakeGatewayRuntime({required super.store}) : super(identityStore: DeviceIdentityStore(store)); diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index e232be84..a2a80a66 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -309,7 +309,7 @@ void main() { find.byKey(const ValueKey('external-acp-provider-add-button')), findsOneWidget, ); - expect(find.text('添加自定义 ACP Server Endpoint'), findsOneWidget); + expect(find.text('添加更多自定义配置'), findsOneWidget); expect(find.textContaining('ws://127.0.0.1:9001'), findsWidgets); expect(find.text('标志'), findsNothing); expect(find.text('Badge'), findsNothing); @@ -323,6 +323,44 @@ void main() { ); }); + testWidgets('SettingsPage ACP wizard adds a custom provider card', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + + await pumpPage( + tester, + child: SettingsPage(controller: controller), + platform: TargetPlatform.macOS, + ); + + await tester.tap(find.text('集成')); + await tester.pumpAndSettle(); + await tester.tap(find.text('ACP 外部接入').first); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('external-acp-provider-add-button')), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('external-acp-wizard-name-field')), + 'Lab Agent', + ); + await tester.enterText( + find.byKey(const ValueKey('external-acp-wizard-endpoint-field')), + 'wss://lab.example.com/acp', + ); + await tester.tap( + find.byKey(const ValueKey('external-acp-wizard-confirm-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Lab Agent'), findsWidgets); + expect(find.text('wss://lab.example.com/acp'), findsWidgets); + }); + testWidgets('SettingsPage skills authorization tab keeps only preset roots', ( WidgetTester tester, ) async { diff --git a/test/runtime/app_controller_ai_gateway_chat_suite.dart b/test/runtime/app_controller_ai_gateway_chat_suite.dart index a49fca64..9edc355e 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite.dart @@ -548,6 +548,88 @@ void main() { ); }, ); + + test( + 'AppController uses the recorded thread workspace for Single Agent runs', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-single-agent-thread-cwd-', + ); + final defaultWorkspace = Directory( + '${tempDirectory.path}/default-workspace', + ); + final threadWorkspace = Directory( + '${tempDirectory.path}/thread-workspace', + ); + await defaultWorkspace.create(recursive: true); + await threadWorkspace.create(recursive: true); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + workspacePath: defaultWorkspace.path, + assistantExecutionTarget: AssistantExecutionTarget.singleAgent, + ), + ); + await store.saveAssistantThreadRecords([ + AssistantThreadRecord( + sessionKey: 'main', + messages: const [], + updatedAtMs: 1, + title: 'Main', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.rendered, + workspaceRef: threadWorkspace.path, + workspaceRefKind: WorkspaceRefKind.localPath, + ), + ]); + + final runner = _FakeSingleAgentRunner( + resolvedProvider: SingleAgentProvider.codex, + result: const SingleAgentRunResult( + provider: SingleAgentProvider.codex, + output: 'THREAD_OK', + success: true, + errorMessage: '', + shouldFallbackToAiChat: false, + ), + ); + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ), + singleAgentRunner: runner, + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + await controller.sendChatMessage('检查当前线程目录', thinking: 'low'); + + expect(runner.runCalls, 1); + expect(runner.lastRequest?.workingDirectory, threadWorkspace.path); + expect( + controller.assistantWorkspaceRefForSession('main'), + threadWorkspace.path, + ); + }, + ); } class _FakeGatewayRuntime extends GatewayRuntime { diff --git a/test/runtime/app_controller_assistant_workspace_ref_test.dart b/test/runtime/app_controller_assistant_workspace_ref_test.dart index ba3c2548..85f8335e 100644 --- a/test/runtime/app_controller_assistant_workspace_ref_test.dart +++ b/test/runtime/app_controller_assistant_workspace_ref_test.dart @@ -1,10 +1,13 @@ @TestOn('vm') library; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; import '../test_support.dart'; @@ -72,4 +75,95 @@ void main() { ); }, ); + + test( + 'AppController preserves recorded workspace refs when switching threads', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-workspace-ref-', + ); + final mainWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-main-thread-', + ); + final taskWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-task-thread-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + if (await mainWorkspace.exists()) { + await mainWorkspace.delete(recursive: true); + } + if (await taskWorkspace.exists()) { + await taskWorkspace.delete(recursive: true); + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveAssistantThreadRecords([ + AssistantThreadRecord( + sessionKey: 'main', + messages: const [], + updatedAtMs: 1, + title: 'Main', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.rendered, + workspaceRef: mainWorkspace.path, + workspaceRefKind: WorkspaceRefKind.localPath, + ), + AssistantThreadRecord( + sessionKey: 'draft:artifact-thread', + messages: const [], + updatedAtMs: 2, + title: 'Artifact Thread', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.rendered, + workspaceRef: taskWorkspace.path, + workspaceRefKind: WorkspaceRefKind.localPath, + ), + ]); + + final controller = AppController(store: store); + addTearDown(controller.dispose); + + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (controller.initializing) { + if (DateTime.now().isAfter(deadline)) { + fail('controller did not initialize in time'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } + + expect( + controller.assistantWorkspaceRefForSession('main'), + mainWorkspace.path, + ); + expect( + controller.assistantWorkspaceRefForSession('draft:artifact-thread'), + taskWorkspace.path, + ); + + await controller.switchSession('draft:artifact-thread'); + expect( + controller.assistantWorkspaceRefForSession('draft:artifact-thread'), + taskWorkspace.path, + ); + + await controller.switchSession('main'); + expect( + controller.assistantWorkspaceRefForSession('main'), + mainWorkspace.path, + ); + }, + ); } diff --git a/test/runtime/external_acp_endpoint_settings_suite.dart b/test/runtime/external_acp_endpoint_settings_suite.dart index 5cd7aa1d..90c6ba19 100644 --- a/test/runtime/external_acp_endpoint_settings_suite.dart +++ b/test/runtime/external_acp_endpoint_settings_suite.dart @@ -63,21 +63,21 @@ void main() { ); }); - test('legacy claude and gemini entries migrate into custom endpoints', () { + test('empty legacy claude and gemini entries are dropped', () { final normalized = normalizeExternalAcpEndpoints( profiles: const [ ExternalAcpEndpointProfile( providerKey: 'claude', label: 'Claude', badge: 'Cl', - endpoint: 'ws://127.0.0.1:9011', + endpoint: '', enabled: true, ), ExternalAcpEndpointProfile( providerKey: 'gemini', label: 'Gemini', badge: 'G', - endpoint: 'ws://127.0.0.1:9012', + endpoint: '', enabled: true, ), ], @@ -88,14 +88,94 @@ void main() { const ['codex', 'opencode'], ); expect( - normalized - .where((item) => item.providerKey.startsWith('custom-agent-')) - .map((item) => item.label) - .toList(growable: false), - const ['Claude', 'Gemini'], + normalized.where( + (item) => item.providerKey.startsWith('custom-agent-'), + ), + isEmpty, ); expect(normalized.any((item) => item.providerKey == 'claude'), isFalse); expect(normalized.any((item) => item.providerKey == 'gemini'), isFalse); }); + + test( + 'configured legacy claude and gemini entries migrate into custom endpoints', + () { + final normalized = normalizeExternalAcpEndpoints( + profiles: const [ + ExternalAcpEndpointProfile( + providerKey: 'claude', + label: 'Claude', + badge: 'Cl', + endpoint: 'wss://claude.example.com/acp', + enabled: true, + ), + ExternalAcpEndpointProfile( + providerKey: 'gemini', + label: 'Gemini', + badge: 'G', + endpoint: 'wss://gemini.example.com/acp', + enabled: true, + ), + ], + ); + + expect( + normalized + .where((item) => item.providerKey.startsWith('custom-agent-')) + .map((item) => item.label) + .toList(growable: false), + const ['Claude', 'Gemini'], + ); + expect(normalized.any((item) => item.providerKey == 'claude'), isFalse); + expect(normalized.any((item) => item.providerKey == 'gemini'), isFalse); + }, + ); + + test( + 'custom endpoint builder validates sequential keys and label fallback', + () { + final profile = buildCustomExternalAcpEndpointProfile( + SettingsSnapshot.defaults().externalAcpEndpoints, + label: '', + endpoint: 'wss://lab.example.com/acp', + ); + + expect(profile.providerKey, 'custom-agent-3'); + expect(profile.label, 'Custom ACP Endpoint 3'); + expect(profile.endpoint, 'wss://lab.example.com/acp'); + }, + ); + + test( + 'available single-agent providers follow normalized endpoint settings', + () { + final snapshot = SettingsSnapshot.defaults().copyWith( + externalAcpEndpoints: normalizeExternalAcpEndpoints( + profiles: [ + ...SettingsSnapshot.defaults().externalAcpEndpoints, + buildCustomExternalAcpEndpointProfile( + SettingsSnapshot.defaults().externalAcpEndpoints, + label: 'Lab Agent', + endpoint: 'wss://lab.example.com/acp', + ), + const ExternalAcpEndpointProfile( + providerKey: 'claude', + label: 'Claude', + badge: 'Cl', + endpoint: '', + enabled: true, + ), + ], + ), + ); + + expect( + snapshot.availableSingleAgentProviders + .map((item) => item.label) + .toList(), + const ['Codex', 'OpenCode', 'Lab Agent'], + ); + }, + ); }); } diff --git a/test/web/web_ui_browser_test.dart b/test/web/web_ui_browser_test.dart index 46b2bac2..e003e9ba 100644 --- a/test/web/web_ui_browser_test.dart +++ b/test/web/web_ui_browser_test.dart @@ -178,7 +178,7 @@ void main() { find.byKey(const ValueKey('web-external-acp-provider-add-button')), findsOneWidget, ); - expect(find.text('添加自定义 ACP Server Endpoint'), findsOneWidget); + expect(find.text('添加更多自定义配置'), findsOneWidget); expect(find.text('标志'), findsNothing); expect(find.text('Badge'), findsNothing); });