Refine assistant provider settings flows

This commit is contained in:
Haitao Pan 2026-03-26 17:55:38 +08:00
parent 508d6cc330
commit 153db679f5
11 changed files with 920 additions and 153 deletions

View File

@ -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;
}

View File

@ -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<XFile?> Function();
@ -396,6 +397,14 @@ class _AssistantPageState extends State<AssistantPage> {
}) {
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<AssistantPage> {
_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<AssistantPage> {
currentTask: currentTask,
items: timelineItems,
messageViewMode: controller.currentAssistantMessageViewMode,
bottomContentInset: composerBottomSpacing,
topTrailingInset: _artifactPaneCollapsed
? _assistantCollapsedArtifactToggleClearance
: 0,
@ -485,6 +496,7 @@ class _AssistantPageState extends State<AssistantPage> {
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<AssistantPage> {
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<AssistantPage> {
_lastAutoAgentLabel =
autoAgent?.name ?? _conversationOwnerLabel(controller);
_lastSubmittedAttachments = attachmentNames;
_attachments = const <_ComposerAttachment>[];
_touchTaskSeed(
sessionKey: controller.currentSessionKey,
title:
@ -831,31 +848,12 @@ class _AssistantPageState extends State<AssistantPage> {
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<AssistantPage> {
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<List<GatewayChatAttachmentPayload>> _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<DetailPanelData> 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),

View File

@ -914,8 +914,8 @@ class _SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
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<SettingsPage> {
);
}
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: <ExternalAcpEndpointProfile>[
...settings.externalAcpEndpoints,
ExternalAcpEndpointProfile(
providerKey: providerKey(),
label: appText(
'自定义 ACP Endpoint $suffix',
'Custom ACP Endpoint $suffix',
),
badge: '',
endpoint: '',
enabled: true,
Future<void> _showAddExternalAcpProviderWizard(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) async {
final nameController = TextEditingController();
final endpointController = TextEditingController();
var attemptedSubmit = false;
try {
final profile = await showDialog<ExternalAcpEndpointProfile>(
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: <ExternalAcpEndpointProfile>[
...settings.externalAcpEndpoints,
profile,
],
),
],
);
);
} finally {
nameController.dispose();
endpointController.dispose();
}
}
Widget _buildLlmEndpointManager(

View File

@ -134,6 +134,23 @@ String _singleAgentProviderFallbackBadge({
return stripped.substring(0, length).toUpperCase();
}
const Set<String> kSupportedExternalAcpEndpointSchemes = <String>{
'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<ExternalAcpEndpointProfile> normalizeExternalAcpEndpoints({
continue;
}
if (kLegacyExternalAcpProviderIds.contains(key)) {
if (item.endpoint.trim().isEmpty) {
continue;
}
migratedCustomProfiles.add(item.copyWith(providerKey: nextCustomKey()));
continue;
}
@ -430,6 +450,38 @@ List<ExternalAcpEndpointProfile> replaceExternalAcpEndpointForProvider(
return normalizeExternalAcpEndpoints(profiles: next);
}
ExternalAcpEndpointProfile buildCustomExternalAcpEndpointProfile(
Iterable<ExternalAcpEndpointProfile> 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) {

View File

@ -926,8 +926,8 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
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<WebSettingsPage> {
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<WebSettingsPage> {
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: <ExternalAcpEndpointProfile>[
...settings.externalAcpEndpoints,
ExternalAcpEndpointProfile(
providerKey: providerKey(),
label: appText(
'自定义 ACP Endpoint $suffix',
'Custom ACP Endpoint $suffix',
),
badge: '$suffix',
endpoint: '',
enabled: true,
Future<void> _showAddExternalAcpProviderWizard(
BuildContext context,
AppController controller,
) async {
final settings = controller.settingsDraft;
final nameController = TextEditingController();
final endpointController = TextEditingController();
var attemptedSubmit = false;
try {
final profile = await showDialog<ExternalAcpEndpointProfile>(
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: <ExternalAcpEndpointProfile>[
...settings.externalAcpEndpoints,
profile,
],
),
],
);
);
} finally {
nameController.dispose();
endpointController.dispose();
}
}
Widget _buildGatewayCard(

View File

@ -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<void>();
await tester.runAsync(() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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<void>.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<TextField>(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<void> _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<void> sendGate;
int sendCallCount = 0;
String lastSentMessage = '';
@override
Future<void> sendChatMessage(
String message, {
String thinking = 'off',
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
List<CollaborationAttachment> localAttachments =
const <CollaborationAttachment>[],
List<String> selectedSkillLabels = const <String>[],
}) async {
sendCallCount += 1;
lastSentMessage = message;
await sendGate.future;
}
}
class _FakeGatewayRuntime extends GatewayRuntime {
_FakeGatewayRuntime({required super.store})
: super(identityStore: DeviceIdentityStore(store));

View File

@ -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 {

View File

@ -548,6 +548,88 @@ void main() {
);
},
);
test(
'AppController uses the recorded thread workspace for Single Agent runs',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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>[
AssistantThreadRecord(
sessionKey: 'main',
messages: const <GatewayChatMessage>[],
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>[
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 {

View File

@ -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(<String, Object>{});
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>[
AssistantThreadRecord(
sessionKey: 'main',
messages: const <GatewayChatMessage>[],
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 <GatewayChatMessage>[],
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<void>.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,
);
},
);
}

View File

@ -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>[
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 <String>['codex', 'opencode'],
);
expect(
normalized
.where((item) => item.providerKey.startsWith('custom-agent-'))
.map((item) => item.label)
.toList(growable: false),
const <String>['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>[
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 <String>['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: <ExternalAcpEndpointProfile>[
...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 <String>['Codex', 'OpenCode', 'Lab Agent'],
);
},
);
});
}

View File

@ -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);
});