Merge branch 'codex/rewrite-task-dialog-modes'

This commit is contained in:
Haitao Pan 2026-04-13 18:03:05 +08:00
commit 5d992f884a
22 changed files with 1251 additions and 280 deletions

View File

@ -13,8 +13,12 @@ PUBSPEC_BUILD_ID := $(shell sed -n 's/^build-id:[[:space:]]*//p' pubspec.yaml |
APP_VERSION := $(firstword $(subst +, ,$(PUBSPEC_VERSION_LINE)))
APP_BUILD_NUMBER_RAW := $(word 2,$(subst +, ,$(PUBSPEC_VERSION_LINE)))
APP_BUILD_NUMBER := $(if $(APP_BUILD_NUMBER_RAW),$(APP_BUILD_NUMBER_RAW),1)
APP_BUILD_DATE := $(if $(PUBSPEC_BUILD_DATE),$(PUBSPEC_BUILD_DATE),unknown)
APP_BUILD_COMMIT := $(if $(PUBSPEC_BUILD_ID),$(PUBSPEC_BUILD_ID),unknown)
APP_DART_DEFINE_VERSION ?= --dart-define=XWORKMATE_DISPLAY_VERSION=$(APP_VERSION)
APP_DART_DEFINE_BUILD ?= --dart-define=XWORKMATE_BUILD_NUMBER=$(APP_BUILD_NUMBER)
APP_DART_DEFINE_BUILD_DATE ?= --dart-define=XWORKMATE_BUILD_DATE=$(APP_BUILD_DATE)
APP_DART_DEFINE_BUILD_COMMIT ?= --dart-define=XWORKMATE_BUILD_COMMIT=$(APP_BUILD_COMMIT)
.PHONY: help deps analyze test test-all test-flutter test-golden test-integration test-integration-macos test-patrol test-go test-ci check format run open-macos-xcode sync-version build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-go-core render-release-docs check-export-compliance test-real-env-login-chain inspect-xworkmate-bridge-service
@ -81,14 +85,14 @@ open-macos-xcode: ## Open the supported macOS Xcode workspace entrypoint
open macos/Runner.xcworkspace
build-linux: ## Build the Linux app in release mode
$(FLUTTER) build linux --release
$(FLUTTER) build linux --release --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) $(APP_DART_DEFINE_BUILD_DATE) $(APP_DART_DEFINE_BUILD_COMMIT)
build-macos: ## Build the macOS app in release mode
$(FLUTTER) build macos --release $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD)
$(FLUTTER) build macos --release $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) $(APP_DART_DEFINE_BUILD_DATE) $(APP_DART_DEFINE_BUILD_COMMIT)
bash scripts/check-apple-export-compliance.sh build/macos/Build/Products/Release/XWorkmate.app
build-ios-sim: ## Build the iOS app for the simulator
$(FLUTTER) build ios --simulator $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD)
$(FLUTTER) build ios --simulator $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) $(APP_DART_DEFINE_BUILD_DATE) $(APP_DART_DEFINE_BUILD_COMMIT)
bash scripts/check-apple-export-compliance.sh build/ios/iphonesimulator/Runner.app
build-go-core: ## Build the external ACP bridge helper from xworkmate-bridge

View File

@ -79,6 +79,10 @@ flowchart TD
- `acp.capabilities.providerCatalog` 是 assistant provider picker 的唯一上游真源
- 持久化在线程上的 `providerId` 只表示用户历史选择,不负责反向生成 catalog
- provider unavailable 文案与 resolved provider 都来自 `xworkmate.routing.resolve`
- 任务对话模式的 provider 菜单按 execution target 分流:
- `agent` 只展示 bridge-owned provider catalog`codex / opencode / gemini`
- `gateway` 只展示 canonical gateway provider`OpenClaw`
- 这里不保留旧的 provider matrix、preset fallback 或双真源选择路径
### Gateway Truth

View File

@ -149,6 +149,7 @@ Status: `Active`
当前 Assistant 事实:
- provider catalog 只来自 bridge capabilities不再恢复任何 preset / backfill / fallback provider truth
- 任务对话模式按 execution target 分流:`智能体` 只提供 `codex / opencode / gemini``Gateway` 只提供 `OpenClaw`
- task state 仍在 assistant 内被消费,但不再拥有独立 `TasksPage`
- skills 数据仍在 assistant 内被消费,但不再拥有独立 `SkillsPage`
- assistant focus 只保留仍有真实落点的 `settings / language / theme`

View File

@ -17,6 +17,7 @@ and the two key client-side parsing assertions:
- `BRIDGE_SERVER_URL` may be retained in account sync metadata, but does not drive runtime endpoint selection
- `BRIDGE_AUTH_TOKEN` is written into secure storage
- account sync no longer parses `INTERNAL_SERVICE_TOKEN` as a bridge token fallback
## Sync Chain
@ -81,5 +82,6 @@ flowchart TD
- The app-facing managed bridge origin is fixed to `https://xworkmate-bridge.svc.plus`.
- `BRIDGE_SERVER_URL`, when present, is metadata only.
- `BRIDGE_AUTH_TOKEN` is the only bridge token field used by the sync contract.
- `INTERNAL_SERVICE_TOKEN` is not part of the app-side account sync token contract.
- `BRIDGE_AUTH_TOKEN` must never be written into normal settings snapshot, profile JSON, or UI-visible text.
- Client requests must assemble the header as `Authorization: Bearer <token>`.

View File

@ -678,7 +678,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
if (matchesBridgeEndpoint) {
final bridgeToken =
runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN') ??
runtimeEnvironmentValueInternal('INTERNAL_SERVICE_TOKEN') ??
(await storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
))?.trim();

View File

@ -11,4 +11,12 @@ const kAppBuildNumber = String.fromEnvironment(
'XWORKMATE_BUILD_NUMBER',
defaultValue: '20260311',
);
const kAppBuildDate = String.fromEnvironment(
'XWORKMATE_BUILD_DATE',
defaultValue: '2026-03-28',
);
const kAppBuildCommit = String.fromEnvironment(
'XWORKMATE_BUILD_COMMIT',
defaultValue: 'f153d7b',
);
const kAppVersionLabel = 'Version $kAppVersion';

View File

@ -35,6 +35,7 @@ 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';
import 'assistant_page_task_dialog_controls.dart';
class ComposerBarInternal extends StatefulWidget {
const ComposerBarInternal({
@ -310,34 +311,7 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final visibleExecutionTargets = controller.visibleAssistantExecutionTargets(
uiFeatures.availableExecutionTargets,
);
final currentExecutionTarget = controller.assistantExecutionTarget;
final executionTarget =
visibleExecutionTargets.contains(currentExecutionTarget)
? currentExecutionTarget
: (visibleExecutionTargets.isNotEmpty
? visibleExecutionTargets.first
: currentExecutionTarget);
final compactExecutionTargets = compactAssistantExecutionTargets(
visibleExecutionTargets,
);
final compactExecutionTarget = collapseAssistantExecutionTargetForDisplay(
executionTarget,
);
final permissionLevel = controller.assistantPermissionLevel;
final availableProviders = controller.assistantProviderCatalog;
final selectedProvider = controller.assistantProviderForSession(
controller.currentSessionKey,
);
final providerMenuProviders = switch (executionTarget) {
AssistantExecutionTarget.gateway =>
selectedProvider.isUnspecified
? const <SingleAgentProvider>[]
: <SingleAgentProvider>[selectedProvider],
AssistantExecutionTarget.agent => availableProviders,
};
final selectedSkills = widget.availableSkills
.where((skill) => widget.selectedSkillKeys.contains(skill.key))
.toList(growable: false);
@ -382,110 +356,8 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
),
const SizedBox(width: 6),
],
if (compactExecutionTargets.isNotEmpty) ...[
PopupMenuButton<AssistantExecutionTarget>(
key: const Key('assistant-execution-target-button'),
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
onSelected: (value) async {
final resolvedTarget =
resolveAssistantExecutionTargetFromVisibleTargets(
visibleExecutionTargets,
currentTarget: value,
);
await controller.setAssistantExecutionTarget(
resolvedTarget,
);
if (mounted) {
setState(() {});
}
},
itemBuilder: (context) => compactExecutionTargets
.map(
(value) => PopupMenuItem<AssistantExecutionTarget>(
value: value,
key: Key(
'assistant-execution-target-menu-item-${value.name}',
),
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.compactLabel)),
if (value == compactExecutionTarget)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: ComposerToolbarChipInternal(
icon: compactExecutionTarget.icon,
tooltip: executionTargetTooltipInternal(
compactExecutionTarget,
),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
],
if (providerMenuProviders.isNotEmpty) ...[
PopupMenuButton<String>(
key: const Key('assistant-provider-button'),
tooltip: appText('智能体 Provider', 'Agent Provider'),
onSelected: (providerId) async {
if (executionTarget.isGateway) {
return;
}
await controller.setAssistantSingleAgentProvider(
controller.resolveAssistantProvider(providerId),
);
if (mounted) {
setState(() {});
}
},
itemBuilder: (context) => providerMenuProviders
.map(
(provider) => PopupMenuItem<String>(
value: provider.providerId,
key: Key(
'assistant-provider-menu-item-${provider.providerId}',
),
child: Row(
children: [
SingleAgentProviderBadgeInternal(
key: Key(
'assistant-provider-menu-badge-${provider.providerId}',
),
provider: provider,
),
const SizedBox(width: 10),
Expanded(child: Text(provider.label)),
if (provider == selectedProvider)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(growable: false),
child: ComposerToolbarChipInternal(
leading: SingleAgentProviderBadgeInternal(
key: const Key('assistant-provider-badge'),
provider: selectedProvider,
),
tooltip: providerTooltipInternal(selectedProvider),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
],
AssistantTaskDialogModeControlsInternal(controller: controller),
const SizedBox(width: 4),
if (widget.showModelControl) ...[
widget.modelOptions.isEmpty
? ComposerToolbarChipInternal(

View File

@ -0,0 +1,254 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/ui_feature_manifest.dart';
import '../../i18n/app_language.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import 'assistant_page_composer_support.dart';
class AssistantTaskDialogModeControlsInternal extends StatelessWidget {
const AssistantTaskDialogModeControlsInternal({
super.key,
required this.controller,
});
final AppController controller;
@override
Widget build(BuildContext context) {
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final visibleExecutionTargets = controller.visibleAssistantExecutionTargets(
uiFeatures.availableExecutionTargets,
);
if (visibleExecutionTargets.isEmpty) {
return const SizedBox.shrink();
}
final currentExecutionTarget =
resolveAssistantExecutionTargetFromVisibleTargets(
visibleExecutionTargets,
currentTarget: controller.assistantExecutionTarget,
);
final executionTarget = collapseAssistantExecutionTargetForDisplay(
currentExecutionTarget,
);
final providerMenuProviders = _taskDialogProviderCatalogForTarget(
controller: controller,
executionTarget: executionTarget,
);
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_TaskDialogExecutionTargetMenuButtonInternal(
controller: controller,
executionTarget: executionTarget,
visibleExecutionTargets: visibleExecutionTargets,
),
if (providerMenuProviders.isNotEmpty)
_TaskDialogProviderMenuButtonInternal(
controller: controller,
executionTarget: executionTarget,
selectedProvider: controller.assistantProviderForSession(
controller.currentSessionKey,
),
providers: providerMenuProviders,
),
],
);
}
}
List<SingleAgentProvider> _taskDialogProviderCatalogForTarget({
required AppController controller,
required AssistantExecutionTarget executionTarget,
}) {
if (executionTarget.isGateway) {
return <SingleAgentProvider>[
controller.assistantProviderForSession(controller.currentSessionKey),
];
}
return controller.assistantProviderCatalog;
}
class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget {
const _TaskDialogExecutionTargetMenuButtonInternal({
required this.controller,
required this.executionTarget,
required this.visibleExecutionTargets,
});
final AppController controller;
final AssistantExecutionTarget executionTarget;
final List<AssistantExecutionTarget> visibleExecutionTargets;
@override
Widget build(BuildContext context) {
final compactExecutionTargets = compactAssistantExecutionTargets(
visibleExecutionTargets,
);
final palette = context.palette;
final selectedLabel = executionTarget.label;
return PopupMenuButton<AssistantExecutionTarget>(
key: const Key('assistant-execution-target-button'),
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
onSelected: (value) {
unawaited(_handleExecutionTargetSelected(value));
},
itemBuilder: (context) => compactExecutionTargets
.map(
(value) => PopupMenuItem<AssistantExecutionTarget>(
value: value,
key: Key('assistant-execution-target-menu-item-${value.name}'),
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value == executionTarget)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(growable: false),
child: _TaskDialogSelectorChipInternal(
leading: Icon(executionTarget.icon, size: 14, color: palette.textMuted),
label: selectedLabel,
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
),
);
}
Future<void> _handleExecutionTargetSelected(
AssistantExecutionTarget value,
) async {
final resolvedTarget = resolveAssistantExecutionTargetFromVisibleTargets(
visibleExecutionTargets,
currentTarget: value,
);
await controller.setAssistantExecutionTarget(resolvedTarget);
}
}
class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
const _TaskDialogProviderMenuButtonInternal({
required this.controller,
required this.executionTarget,
required this.selectedProvider,
required this.providers,
});
final AppController controller;
final AssistantExecutionTarget executionTarget;
final SingleAgentProvider selectedProvider;
final List<SingleAgentProvider> providers;
@override
Widget build(BuildContext context) {
final displayProvider = selectedProvider.isUnspecified
? SingleAgentProvider.openclaw
: selectedProvider;
return PopupMenuButton<SingleAgentProvider>(
key: const Key('assistant-provider-button'),
tooltip: appText('智能体 Provider', 'Agent Provider'),
onSelected: (provider) {
unawaited(_handleProviderSelected(provider));
},
itemBuilder: (context) => providers
.map(
(provider) => PopupMenuItem<SingleAgentProvider>(
value: provider,
key: Key('assistant-provider-menu-item-${provider.providerId}'),
child: Row(
children: [
SingleAgentProviderBadgeInternal(
key: Key(
'assistant-provider-menu-badge-${provider.providerId}',
),
provider: provider,
),
const SizedBox(width: 10),
Expanded(child: Text(provider.label)),
if (provider == displayProvider)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(growable: false),
child: _TaskDialogSelectorChipInternal(
leading: SingleAgentProviderBadgeInternal(
key: const Key('assistant-provider-badge'),
provider: displayProvider,
),
label: displayProvider.label,
tooltip: appText('智能体 Provider', 'Agent Provider'),
),
);
}
Future<void> _handleProviderSelected(SingleAgentProvider provider) async {
if (executionTarget.isGateway) {
return;
}
await controller.setAssistantSingleAgentProvider(provider);
}
}
class _TaskDialogSelectorChipInternal extends StatelessWidget {
const _TaskDialogSelectorChipInternal({
required this.leading,
required this.label,
required this.tooltip,
});
final Widget leading;
final String label;
final String tooltip;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Tooltip(
message: tooltip,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: 5,
),
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.chip),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
leading,
const SizedBox(width: 6),
Text(label, style: theme.textTheme.labelMedium),
const SizedBox(width: 2),
Icon(
Icons.keyboard_arrow_down_rounded,
size: 14,
color: palette.textMuted,
),
],
),
),
);
}
}

View File

@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import '../../i18n/app_language.dart';
@immutable
class SettingsAboutSnapshot {
const SettingsAboutSnapshot({
required this.appVersion,
required this.appBuildNumber,
required this.appBuildDate,
required this.appCommit,
required this.bridgeEndpoint,
required this.bridgeStatus,
required this.bridgeVersion,
required this.bridgeBuildDate,
required this.bridgeCommit,
required this.bridgeImage,
});
final String appVersion;
final String appBuildNumber;
final String appBuildDate;
final String appCommit;
final String bridgeEndpoint;
final String bridgeStatus;
final String bridgeVersion;
final String bridgeBuildDate;
final String bridgeCommit;
final String bridgeImage;
const SettingsAboutSnapshot.defaults()
: appVersion = '',
appBuildNumber = '',
appBuildDate = '',
appCommit = '',
bridgeEndpoint = '',
bridgeStatus = '',
bridgeVersion = '',
bridgeBuildDate = '',
bridgeCommit = '',
bridgeImage = '';
}
class SettingsAboutPanel extends StatelessWidget {
const SettingsAboutPanel({
super.key,
required this.snapshot,
required this.busy,
required this.onRefresh,
});
final SettingsAboutSnapshot snapshot;
final bool busy;
final Future<void> Function() onRefresh;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('关于', 'About'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'直接查看当前应用构建与 Bridge 运行时版本信息,便于排查发布与联调问题。',
'Review the current app build and bridge runtime information in one place.',
),
),
],
),
),
const SizedBox(width: 16),
FilledButton.tonal(
key: const ValueKey('settings-about-refresh-button'),
onPressed: busy ? null : () => onRefresh(),
child: busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
key: const ValueKey(
'settings-about-refresh-progress',
),
strokeWidth: 2,
),
),
const SizedBox(width: 8),
Text(appText('刷新中', 'Refreshing')),
],
)
: Text(appText('刷新版本信息', 'Refresh Build Info')),
),
],
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('应用构建', 'App Build'),
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
'${appText('Version', 'Version')}: ${_displayValue(snapshot.appVersion)}',
key: const ValueKey('settings-about-app-version'),
),
const SizedBox(height: 6),
Text(
'${appText('Build Number', 'Build Number')}: ${_displayValue(snapshot.appBuildNumber)}',
key: const ValueKey('settings-about-app-build-number'),
),
const SizedBox(height: 6),
Text(
'${appText('Build Date', 'Build Date')}: ${_displayValue(snapshot.appBuildDate)}',
key: const ValueKey('settings-about-app-build-date'),
),
const SizedBox(height: 6),
Text(
'Commit: ${_displayValue(snapshot.appCommit)}',
key: const ValueKey('settings-about-app-commit'),
),
const SizedBox(height: 18),
Text(
appText('Bridge 运行时', 'Bridge Runtime'),
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
'${appText('Endpoint', 'Endpoint')}: ${_displayValue(snapshot.bridgeEndpoint)}',
key: const ValueKey('settings-about-bridge-endpoint'),
),
const SizedBox(height: 6),
Text(
'${appText('Status', 'Status')}: ${_displayValue(snapshot.bridgeStatus)}',
key: const ValueKey('settings-about-bridge-status'),
),
const SizedBox(height: 6),
Text(
'${appText('Version', 'Version')}: ${_displayValue(snapshot.bridgeVersion)}',
key: const ValueKey('settings-about-bridge-version'),
),
const SizedBox(height: 6),
Text(
'${appText('Build Date', 'Build Date')}: ${_displayValue(snapshot.bridgeBuildDate)}',
key: const ValueKey('settings-about-bridge-build-date'),
),
const SizedBox(height: 6),
Text(
'Commit: ${_displayValue(snapshot.bridgeCommit)}',
key: const ValueKey('settings-about-bridge-commit'),
),
const SizedBox(height: 6),
Text(
'${appText('Image', 'Image')}: ${_displayValue(snapshot.bridgeImage)}',
key: const ValueKey('settings-about-bridge-image'),
),
],
),
),
],
);
}
}
String _displayValue(String value) {
return value.trim().isEmpty ? appText('不可用', 'Unavailable') : value.trim();
}

View File

@ -10,6 +10,7 @@ class SettingsAccountPanel extends StatelessWidget {
required this.accountSession,
required this.accountState,
required this.accountBusy,
this.accountStatus = '',
required this.accountSignedIn,
required this.accountMfaRequired,
required this.accountBaseUrlController,
@ -28,6 +29,7 @@ class SettingsAccountPanel extends StatelessWidget {
final AccountSessionSummary? accountSession;
final AccountSyncState? accountState;
final bool accountBusy;
final String accountStatus;
final bool accountSignedIn;
final bool accountMfaRequired;
final TextEditingController accountBaseUrlController;
@ -68,6 +70,7 @@ class SettingsAccountPanel extends StatelessWidget {
accountSession: accountSession,
accountState: accountState,
accountBusy: accountBusy,
accountStatus: accountStatus,
onSync: onSync,
onLogout: onLogout,
);
@ -284,6 +287,7 @@ class _SignedInAccountPanel extends StatelessWidget {
required this.accountSession,
required this.accountState,
required this.accountBusy,
required this.accountStatus,
required this.onSync,
required this.onLogout,
});
@ -292,6 +296,7 @@ class _SignedInAccountPanel extends StatelessWidget {
final AccountSessionSummary? accountSession;
final AccountSyncState? accountState;
final bool accountBusy;
final String accountStatus;
final Future<void> Function() onSync;
final Future<void> Function() onLogout;
@ -316,6 +321,11 @@ class _SignedInAccountPanel extends StatelessWidget {
final syncMessage = accountState?.syncMessage.trim().isNotEmpty == true
? accountState!.syncMessage.trim()
: appText('尚未同步远端配置', 'Remote config not synced yet');
final effectiveSyncState = accountBusy ? 'syncing' : syncState;
final effectiveSyncMessage =
accountBusy && accountStatus.trim().isNotEmpty
? accountStatus.trim()
: syncMessage;
final mfaEnabled =
accountSession?.totpEnabled == true ||
accountSession?.mfaEnabled == true;
@ -342,7 +352,7 @@ class _SignedInAccountPanel extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'${appText('同步状态', 'Sync Status')}: $syncState · $syncMessage',
'${appText('同步状态', 'Sync Status')}: $effectiveSyncState · $effectiveSyncMessage',
key: const ValueKey('settings-account-sync-status'),
style: Theme.of(context).textTheme.bodySmall,
),
@ -413,7 +423,25 @@ class _SignedInAccountPanel extends StatelessWidget {
FilledButton.tonal(
key: const ValueKey('settings-account-sync-button'),
onPressed: accountBusy ? null : () => onSync(),
child: Text(appText('重新同步', 'Sync Again')),
child: accountBusy
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
key: const ValueKey(
'settings-account-sync-progress',
),
strokeWidth: 2,
),
),
const SizedBox(width: 8),
Text(appText('同步中', 'Syncing')),
],
)
: Text(appText('重新同步', 'Sync Again')),
),
TextButton(
key: const ValueKey('settings-account-logout-button'),

View File

@ -1,6 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_metadata.dart';
import '../../app/workspace_navigation.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
@ -9,6 +14,7 @@ import '../../runtime/runtime_models.dart';
import '../../widgets/settings_page_shell.dart';
import '../../widgets/surface_card.dart';
import 'settings_account_panel.dart';
import 'settings_about_panel.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({
@ -34,6 +40,8 @@ class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _accountIdentifierController;
late final TextEditingController _accountPasswordController;
late final TextEditingController _accountMfaCodeController;
SettingsAboutSnapshot _aboutSnapshot = const SettingsAboutSnapshot.defaults();
bool _aboutBusy = false;
String _lastSavedAccountBaseUrl = '';
String _lastSavedAccountIdentifier = '';
@ -51,6 +59,7 @@ class _SettingsPageState extends State<SettingsPage> {
);
_accountPasswordController = TextEditingController();
_accountMfaCodeController = TextEditingController();
unawaited(_refreshAboutSnapshot());
}
@override
@ -108,6 +117,7 @@ class _SettingsPageState extends State<SettingsPage> {
baseUrl: _accountBaseUrlController.text.trim(),
);
await _refreshBridgeCapabilities();
await _refreshAboutSnapshot();
}
Future<void> _verifyAccountMfa(SettingsSnapshot settings) async {
@ -150,6 +160,87 @@ class _SettingsPageState extends State<SettingsPage> {
await widget.controller.settingsController.logoutAccount();
_accountPasswordController.clear();
_accountMfaCodeController.clear();
await _refreshAboutSnapshot();
}
Future<void> _refreshAboutSnapshot() async {
if (!mounted) {
return;
}
setState(() {
_aboutBusy = true;
});
final snapshot = await _loadAboutSnapshot();
if (!mounted) {
return;
}
setState(() {
_aboutSnapshot = snapshot;
_aboutBusy = false;
});
}
Future<SettingsAboutSnapshot> _loadAboutSnapshot() async {
final bridgeMetadata = await _loadBridgeMetadata();
return SettingsAboutSnapshot(
appVersion: kAppVersion,
appBuildNumber: kAppBuildNumber,
appBuildDate: kAppBuildDate,
appCommit: kAppBuildCommit,
bridgeEndpoint: kManagedBridgeServerUrl,
bridgeStatus: _stringValue(bridgeMetadata['status']),
bridgeVersion: _resolveBridgeVersion(bridgeMetadata),
bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata),
bridgeCommit: _stringValue(bridgeMetadata['commit']),
bridgeImage: _stringValue(bridgeMetadata['image']),
);
}
Future<Map<String, dynamic>> _loadBridgeMetadata() async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 4);
try {
final request = await client
.getUrl(Uri.parse('$kManagedBridgeServerUrl/api/ping'))
.timeout(const Duration(seconds: 4));
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
final response = await request.close().timeout(const Duration(seconds: 4));
final body = await utf8
.decodeStream(response)
.timeout(const Duration(seconds: 4));
if (response.statusCode < 200 || response.statusCode >= 300) {
return <String, dynamic>{
'status': 'error',
'version': '',
'commit': '',
'image': '',
'buildDate': '',
};
}
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return decoded.cast<String, dynamic>();
}
} catch (_) {
return const <String, dynamic>{
'status': 'unavailable',
'version': '',
'commit': '',
'image': '',
'buildDate': '',
};
} finally {
client.close(force: true);
}
return const <String, dynamic>{
'status': 'unavailable',
'version': '',
'commit': '',
'image': '',
'buildDate': '',
};
}
@override
@ -165,6 +256,7 @@ class _SettingsPageState extends State<SettingsPage> {
_syncAccountControllers(currentSettings);
final accountState = controller.settingsController.accountSyncState;
final accountBusy = controller.settingsController.accountBusy;
final accountStatus = controller.settingsController.accountStatus;
final accountSignedIn = controller.settingsController.accountSignedIn;
final accountMfaRequired =
controller.settingsController.accountMfaRequired;
@ -201,6 +293,7 @@ class _SettingsPageState extends State<SettingsPage> {
accountSession: accountSession,
accountState: accountState,
accountBusy: accountBusy,
accountStatus: accountStatus,
accountSignedIn: accountSignedIn,
accountMfaRequired: accountMfaRequired,
accountBaseUrlController: _accountBaseUrlController,
@ -216,9 +309,50 @@ class _SettingsPageState extends State<SettingsPage> {
onLogout: _logoutAccount,
),
),
const SizedBox(height: 24),
SurfaceCard(
key: const ValueKey('settings-about-panel-card'),
child: SettingsAboutPanel(
snapshot: _aboutSnapshot,
busy: _aboutBusy,
onRefresh: _refreshAboutSnapshot,
),
),
],
);
},
);
}
}
String _stringValue(Object? value) {
return value == null ? '' : value.toString().trim();
}
String _resolveBridgeVersion(Map<String, dynamic> payload) {
final explicit = _stringValue(payload['version']);
if (explicit.isNotEmpty) {
return explicit;
}
final tag = _stringValue(payload['tag']);
if (tag.isNotEmpty) {
return tag;
}
return '';
}
String _resolveBridgeBuildDate(Map<String, dynamic> payload) {
final candidates = <Object?>[
payload['buildDate'],
payload['build-date'],
payload['builtAt'],
payload['build_at'],
];
for (final candidate in candidates) {
final value = _stringValue(candidate);
if (value.isNotEmpty) {
return value;
}
}
return '';
}

View File

@ -153,13 +153,17 @@ class AccountRuntimeClient {
}
Future<AccountSessionSummary> loadSession({required String token}) async {
final payload = await _requestJson(
final payload = await loadProfile(token: token);
final user = _asMap(payload['user']);
return _accountSessionSummaryFromUserJson(user);
}
Future<Map<String, dynamic>> loadProfile({required String token}) {
return _requestJson(
method: 'GET',
path: '/api/auth/session',
bearerToken: token,
);
final user = _asMap(payload['user']);
return _accountSessionSummaryFromUserJson(user);
}
Future<BridgeBootstrapIssue> createBridgeBootstrapTicket({

View File

@ -147,6 +147,7 @@ Future<void> completeAccountSignInSettingsInternal(
baseUrl: baseUrl,
bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload),
bridgeServerUrlOverride: _resolveBridgeServerUrl(payload),
profilePayloadOverride: payload,
quiet: true,
);
await controller.reloadDerivedStateInternal();
@ -179,7 +180,8 @@ Future<void> restoreAccountSessionSettingsInternal(
try {
final client = controller.buildAccountClient(normalizedBaseUrl);
final session = await client.loadSession(token: token);
final payload = await client.loadProfile(token: token);
final session = _accountSessionSummaryFromUserPayload(_asMap(payload['user']));
await controller.storeInternal.saveAccountSessionSummary(session);
if (session.userId.trim().isNotEmpty) {
await controller.storeInternal.saveAccountSessionUserId(session.userId);
@ -198,6 +200,7 @@ Future<void> restoreAccountSessionSettingsInternal(
await syncAccountSettingsInternal(
controller,
baseUrl: normalizedBaseUrl,
profilePayloadOverride: payload,
quiet: true,
);
} on AccountRuntimeException catch (error) {
@ -225,28 +228,21 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
bool quiet = false,
String bridgeTokenOverride = '',
String bridgeServerUrlOverride = '',
Map<String, dynamic> profilePayloadOverride = const <String, dynamic>{},
}) async {
final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal(
baseUrl,
fallback: controller.snapshotInternal.accountBaseUrl,
);
final sessionToken =
(await controller.storeInternal.loadAccountSessionToken())?.trim() ?? '';
if (sessionToken.isEmpty) {
final nextState = AccountSyncState.defaults().copyWith(
syncState: 'blocked',
syncMessage: 'Account session is unavailable',
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncError: 'Account session is unavailable',
profileScope: 'bridge',
);
await _persistAccountSyncStateInternal(controller, nextState);
const result = AccountSyncResult(
return _persistAccountSyncFailureInternal(
controller,
state: 'blocked',
message: 'Account session is unavailable',
quiet: quiet,
);
controller.accountStatusInternal = result.message;
if (!quiet) {
controller.accountBusyInternal = false;
controller.notifyListeners();
}
return result;
}
if (!quiet) {
@ -255,100 +251,125 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
controller.notifyListeners();
}
final bridgeToken = bridgeTokenOverride.trim().isNotEmpty
? bridgeTokenOverride.trim()
: ((await controller.storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
))?.trim() ??
'');
if (bridgeToken.isEmpty) {
const result = AccountSyncResult(
state: 'blocked',
message: 'Bridge authorization is unavailable',
);
await _persistAccountSyncStateInternal(
try {
Map<String, dynamic> profilePayload = profilePayloadOverride;
if (profilePayload.isEmpty) {
if (normalizedBaseUrl.isEmpty) {
return _persistAccountSyncFailureInternal(
controller,
state: 'blocked',
message: 'Account base URL is required',
quiet: quiet,
);
}
final client = controller.buildAccountClient(normalizedBaseUrl);
profilePayload = await client.loadProfile(token: sessionToken);
}
await _persistAccountSessionSummaryFromProfilePayloadInternal(
controller,
AccountSyncState.defaults().copyWith(
syncState: result.state,
syncMessage: result.message,
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncError: result.message,
profileScope: 'bridge',
profilePayload,
);
final profileBridgeToken = _resolveBridgeAuthorizationToken(profilePayload);
final bridgeToken = bridgeTokenOverride.trim().isNotEmpty
? bridgeTokenOverride.trim()
: profileBridgeToken.trim().isNotEmpty
? profileBridgeToken.trim()
: ((await controller.storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
))?.trim() ??
'');
if (bridgeToken.isEmpty) {
return _persistAccountSyncFailureInternal(
controller,
state: 'blocked',
message: 'Bridge authorization is unavailable',
quiet: quiet,
);
}
await controller.storeInternal.saveAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
value: bridgeToken,
);
final resolvedBridgeServerUrl = _resolveCurrentBridgeServerUrl(
controller,
bridgeServerUrlOverride: bridgeServerUrlOverride.trim().isNotEmpty
? bridgeServerUrlOverride
: _resolveBridgeServerUrl(profilePayload),
);
await controller.storeInternal.clearAccountManagedSecret(
target: kAccountManagedSecretTargetAIGatewayAccessToken,
);
await controller.storeInternal.clearAccountManagedSecret(
target: kAccountManagedSecretTargetOllamaCloudApiKey,
);
final nextState = AccountSyncState.defaults().copyWith(
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
bridgeServerUrl: resolvedBridgeServerUrl,
),
syncState: 'ready',
syncMessage: 'Bridge access synced',
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncSource: resolvedBridgeServerUrl,
lastSyncError: '',
profileScope: 'bridge',
tokenConfigured: const AccountTokenConfigured(
bridge: true,
vault: false,
apisix: false,
),
);
controller.accountStatusInternal = result.message;
await controller.storeInternal.saveAccountSyncState(nextState);
final currentSettings = controller.snapshotInternal;
final currentModeConfig = currentSettings.acpBridgeServerModeConfig;
final nextModeConfig = currentModeConfig.copyWith(
cloudSynced: currentModeConfig.cloudSynced.copyWith(
accountBaseUrl: '',
accountIdentifier: '',
lastSyncAt: nextState.lastSyncAtMs,
remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary
.copyWith(
endpoint: resolvedBridgeServerUrl,
hasAdvancedOverrides: false,
),
),
);
final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings(
currentSettings.copyWith(acpBridgeServerModeConfig: nextModeConfig),
);
if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) {
await controller.saveSnapshot(sanitizedSettings);
}
await controller.reloadDerivedStateInternal();
final email = controller.accountSessionInternal?.email.trim() ?? '';
controller.accountStatusInternal = email.isEmpty
? 'Signed in'
: 'Signed in as $email';
if (!quiet) {
controller.accountBusyInternal = false;
controller.notifyListeners();
}
return result;
return const AccountSyncResult(
state: 'ready',
message: 'Bridge access synced',
);
} on AccountRuntimeException catch (error) {
return _persistAccountSyncFailureInternal(
controller,
state: 'error',
message: error.message,
quiet: quiet,
);
} catch (error) {
return _persistAccountSyncFailureInternal(
controller,
state: 'error',
message: error.toString(),
quiet: quiet,
);
}
await controller.storeInternal.saveAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
value: bridgeToken,
);
final resolvedBridgeServerUrl = _resolveCurrentBridgeServerUrl(
controller,
bridgeServerUrlOverride: bridgeServerUrlOverride,
);
await controller.storeInternal.clearAccountManagedSecret(
target: kAccountManagedSecretTargetAIGatewayAccessToken,
);
await controller.storeInternal.clearAccountManagedSecret(
target: kAccountManagedSecretTargetOllamaCloudApiKey,
);
final nextState = AccountSyncState.defaults().copyWith(
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
bridgeServerUrl: resolvedBridgeServerUrl,
),
syncState: 'ready',
syncMessage: 'Bridge access synced',
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncSource: resolvedBridgeServerUrl,
lastSyncError: '',
profileScope: 'bridge',
tokenConfigured: const AccountTokenConfigured(
bridge: true,
vault: false,
apisix: false,
),
);
await controller.storeInternal.saveAccountSyncState(nextState);
final currentSettings = controller.snapshotInternal;
final currentModeConfig = currentSettings.acpBridgeServerModeConfig;
final nextModeConfig = currentModeConfig.copyWith(
cloudSynced: currentModeConfig.cloudSynced.copyWith(
accountBaseUrl: '',
accountIdentifier: '',
lastSyncAt: nextState.lastSyncAtMs,
remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary
.copyWith(
endpoint: resolvedBridgeServerUrl,
hasAdvancedOverrides: false,
),
),
);
final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings(
currentSettings.copyWith(acpBridgeServerModeConfig: nextModeConfig),
);
if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) {
await controller.saveSnapshot(sanitizedSettings);
}
await controller.reloadDerivedStateInternal();
final email = controller.accountSessionInternal?.email.trim() ?? '';
controller.accountStatusInternal = email.isEmpty
? 'Signed in'
: 'Signed in as $email';
if (!quiet) {
controller.accountBusyInternal = false;
controller.notifyListeners();
}
return const AccountSyncResult(
state: 'ready',
message: 'Bridge access synced',
);
}
Future<void> logoutAccountSettingsInternal(
@ -456,22 +477,64 @@ SettingsSnapshot _sanitizeBridgeOnlyAccountSyncSettings(
);
}
Future<void> _persistAccountSessionSummaryFromProfilePayloadInternal(
SettingsController controller,
Map<String, dynamic> payload,
) async {
final user = _asMap(payload['user']);
if (user.isEmpty) {
return;
}
final summary = _accountSessionSummaryFromUserPayload(user);
final hasSessionDetails =
summary.userId.trim().isNotEmpty ||
summary.email.trim().isNotEmpty ||
summary.name.trim().isNotEmpty ||
summary.role.trim().isNotEmpty;
if (!hasSessionDetails) {
return;
}
await controller.storeInternal.saveAccountSessionSummary(summary);
if (summary.userId.trim().isNotEmpty) {
await controller.storeInternal.saveAccountSessionUserId(summary.userId);
}
final identifier = summary.email.trim().isNotEmpty
? summary.email.trim()
: (await controller.storeInternal.loadAccountSessionIdentifier())
?.trim() ??
controller.snapshotInternal.accountUsername.trim();
if (identifier.isNotEmpty) {
await controller.storeInternal.saveAccountSessionIdentifier(identifier);
}
}
Future<AccountSyncResult> _persistAccountSyncFailureInternal(
SettingsController controller, {
required String state,
required String message,
required bool quiet,
}) async {
await _persistAccountSyncStateInternal(
controller,
AccountSyncState.defaults().copyWith(
syncState: state,
syncMessage: message,
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncError: message,
profileScope: 'bridge',
),
);
controller.accountStatusInternal = message;
if (!quiet) {
controller.accountBusyInternal = false;
controller.notifyListeners();
}
return AccountSyncResult(state: state, message: message);
}
String _resolveBridgeAuthorizationToken(Map<String, dynamic> payload) {
final explicit = _stringValue(payload['BRIDGE_AUTH_TOKEN']);
if (explicit.isNotEmpty) {
return explicit;
}
final uppercaseInternalServiceToken = _stringValue(
payload['INTERNAL_SERVICE_TOKEN'],
);
if (uppercaseInternalServiceToken.isNotEmpty) {
return uppercaseInternalServiceToken;
}
final internalServiceToken = _stringValue(payload['internalServiceToken']);
if (internalServiceToken.isNotEmpty) {
return internalServiceToken;
}
return '';
return explicit;
}
String _resolveBridgeServerUrl(Map<String, dynamic> payload) {

View File

@ -50,12 +50,16 @@ if [[ -z "$VERSION_LINE" ]]; then
echo "Unable to read version from $PUBSPEC_PATH" >&2
exit 1
fi
BUILD_DATE_LINE="$(sed -n 's/^build-date:[[:space:]]*//p' "$PUBSPEC_PATH" | head -n 1)"
BUILD_ID_LINE="$(sed -n 's/^build-id:[[:space:]]*//p' "$PUBSPEC_PATH" | head -n 1)"
APP_VERSION="${VERSION_LINE%%+*}"
APP_BUILD="${VERSION_LINE#*+}"
if [[ "$APP_BUILD" == "$VERSION_LINE" ]]; then
APP_BUILD="1"
fi
APP_BUILD_DATE="${BUILD_DATE_LINE:-unknown}"
APP_BUILD_COMMIT="${BUILD_ID_LINE:-unknown}"
BUILD_APP_PATH="$APP_DIR/build/macos/Build/Products/$PRODUCTS_DIR_NAME/$APP_NAME.app"
DIST_APP_PATH="$DIST_DIR/$APP_NAME.app"
@ -77,6 +81,8 @@ BUILD_ARGS=(
--build-number="$APP_BUILD"
--dart-define="XWORKMATE_DISPLAY_VERSION=$APP_VERSION"
--dart-define="XWORKMATE_BUILD_NUMBER=$APP_BUILD"
--dart-define="XWORKMATE_BUILD_DATE=$APP_BUILD_DATE"
--dart-define="XWORKMATE_BUILD_COMMIT=$APP_BUILD_COMMIT"
"$APP_STORE_DEFINE"
)

View File

@ -10,7 +10,7 @@ import 'package:xworkmate/widgets/surface_card.dart';
void main() {
group('AssistantLowerPaneInternal', () {
testWidgets('shows agent and gateway task dialog modes', (tester) async {
testWidgets('shows mode-specific provider catalogs', (tester) async {
final controller = AppController(
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
@ -27,28 +27,51 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('assistant-provider-button')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('assistant-provider-button')),
find.byKey(const Key('assistant-provider-menu-item-codex')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-opencode')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget,
);
await tester.tap(
find.byKey(const Key('assistant-execution-target-button')),
find.byKey(const Key('assistant-provider-menu-item-codex')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-execution-target-menu-item-gateway')),
find.byKey(const Key('assistant-execution-target-button')),
findsOneWidget,
);
await tester.tap(
find.byKey(const Key('assistant-execution-target-menu-item-gateway')),
final gatewayThread = controller
.requireTaskThreadForSessionInternal('session-1')
.copyWith(
executionBinding: ExecutionBinding(
executionMode: threadExecutionModeFromAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
),
executorId: SingleAgentProvider.openclaw.providerId,
providerId: SingleAgentProvider.openclaw.providerId,
endpointId: '',
executionModeSource: ThreadSelectionSource.explicit,
providerSource: ThreadSelectionSource.explicit,
),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
controller.taskThreadRepositoryInternal.replace(
gatewayThread,
persist: false,
);
controller.notifyListeners();
await tester.pumpAndSettle();
expect(controller.assistantExecutionTarget.name, 'gateway');
@ -64,18 +87,43 @@ void main() {
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-codex')),
findsNothing,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-opencode')),
findsNothing,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsNothing,
);
await tester.tap(
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('assistant-execution-target-button')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
final agentThread = controller
.requireTaskThreadForSessionInternal('session-1')
.copyWith(
executionBinding: ExecutionBinding(
executionMode: threadExecutionModeFromAssistantExecutionTarget(
AssistantExecutionTarget.agent,
),
executorId: SingleAgentProvider.codex.providerId,
providerId: SingleAgentProvider.codex.providerId,
endpointId: '',
executionModeSource: ThreadSelectionSource.explicit,
providerSource: ThreadSelectionSource.explicit,
),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
controller.taskThreadRepositoryInternal.replace(
agentThread,
persist: false,
);
controller.notifyListeners();
await tester.pumpAndSettle();
expect(controller.assistantExecutionTarget.name, 'agent');
@ -83,6 +131,26 @@ void main() {
find.byKey(const Key('assistant-provider-button')),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('assistant-provider-button')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('assistant-provider-menu-item-codex')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
findsNothing,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-opencode')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget,
);
});
testWidgets('shows assistant providers and allows switching provider', (

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/features/settings/settings_about_panel.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/widgets/surface_card.dart';
void main() {
testWidgets('renders app and bridge build metadata clearly', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Material(
child: Center(
child: SizedBox(
width: 1100,
child: SurfaceCard(
child: SettingsAboutPanel(
snapshot: const SettingsAboutSnapshot(
appVersion: '1.0.0-beta.2',
appBuildNumber: '4',
appBuildDate: '2026-03-28',
appCommit: 'f153d7b',
bridgeEndpoint: 'https://xworkmate-bridge.svc.plus',
bridgeStatus: 'ok',
bridgeVersion: '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
bridgeBuildDate: '2026-04-13T09:00:00Z',
bridgeCommit: '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
bridgeImage:
'ghcr.io/x-evor/xworkmate-bridge:991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
),
busy: false,
onRefresh: () async {},
),
),
),
),
),
),
);
expect(find.text('关于'), findsOneWidget);
expect(
find.byKey(const ValueKey('settings-about-app-version')),
findsOneWidget,
);
expect(
find.textContaining('Version: 1.0.0-beta.2'),
findsOneWidget,
);
expect(
find.textContaining('Build Date: 2026-03-28'),
findsOneWidget,
);
expect(
find.textContaining('Commit: 991ecb0ae2f270cdf6cc7bd456d4391cce664ae2'),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('settings-about-refresh-button')),
findsOneWidget,
);
});
}

View File

@ -216,6 +216,56 @@ void main() {
findsNothing,
);
});
testWidgets('shows live syncing feedback while resync is running', (
tester,
) async {
final controllers = _TestControllers();
addTearDown(controllers.dispose);
await tester.pumpWidget(
_buildTestApp(
child: SettingsAccountPanel(
settings: SettingsSnapshot.defaults().copyWith(
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus',
),
accountSession: const AccountSessionSummary(
userId: 'u-1',
email: 'review@svc.plus',
name: 'Review User',
role: 'operator',
mfaEnabled: true,
),
accountState: AccountSyncState.defaults().copyWith(
syncState: 'ready',
syncMessage: 'Bridge access synced',
profileScope: 'bridge',
),
accountBusy: true,
accountStatus: 'Syncing bridge access...',
accountSignedIn: true,
accountMfaRequired: false,
accountBaseUrlController: controllers.baseUrl,
accountIdentifierController: controllers.identifier,
accountPasswordController: controllers.password,
accountMfaCodeController: controllers.mfaCode,
onSaveAccountProfile: () async {},
onLogin: () async {},
onVerifyMfa: () async {},
onCancelMfa: () async {},
onSync: () async {},
onLogout: () async {},
),
),
);
expect(find.textContaining('Syncing bridge access...'), findsOneWidget);
final syncButton = tester.widget<FilledButton>(
find.byKey(const ValueKey('settings-account-sync-button')),
);
expect(syncButton.onPressed, isNull);
});
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/features/settings/settings_about_panel.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/widgets/surface_card.dart';
void main() {
testWidgets('settings about panel matches baseline', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 780));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Material(
child: Center(
child: SizedBox(
width: 1100,
child: SurfaceCard(
child: SettingsAboutPanel(
snapshot: const SettingsAboutSnapshot(
appVersion: '1.0.0-beta.2',
appBuildNumber: '4',
appBuildDate: '2026-03-28',
appCommit: 'f153d7b',
bridgeEndpoint: 'https://xworkmate-bridge.svc.plus',
bridgeStatus: 'ok',
bridgeVersion: '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
bridgeBuildDate: '',
bridgeCommit:
'991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
bridgeImage:
'ghcr.io/x-evor/xworkmate-bridge:991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
),
busy: false,
onRefresh: () async {},
),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(
find.byType(SettingsAboutPanel),
matchesGoldenFile('goldens/settings_about_panel/default.png'),
);
});
}

View File

@ -118,6 +118,25 @@ void main() {
},
);
test(
'ignores legacy INTERNAL_SERVICE_TOKEN for managed bridge auth resolution',
() async {
final controller = AppController(
environmentOverride: const <String, String>{
'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token',
},
);
addTearDown(controller.dispose);
final bridgeHeader = await controller
.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'),
);
expect(bridgeHeader, isNull);
},
);
test(
'runtime coordinator only exposes remote and offline gateway modes',
() {

View File

@ -34,7 +34,14 @@ void main() {
);
await store.saveAccountSessionToken('session-token');
final controller = SettingsController(store);
final client = _FakeAccountRuntimeClient(
loginPayload: const <String, dynamic>{},
profilePayload: const <String, dynamic>{},
);
final controller = SettingsController(
store,
accountClientFactory: (_) => client,
);
addTearDown(controller.dispose);
await controller.initialize();
@ -56,12 +63,11 @@ void main() {
'Bridge authorization is unavailable',
);
expect(controller.accountStatus, 'Bridge authorization is unavailable');
expect(client.loadProfileCallCount, 1);
},
);
test(
'login sync stores the current bridge contract from login payload',
() async {
test('login sync stores BRIDGE_AUTH_TOKEN from login payload', () async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-account-sync-uppercase-token-',
);
@ -89,7 +95,7 @@ void main() {
accountClientFactory: (_) => _FakeAccountRuntimeClient(
loginPayload: <String, dynamic>{
'token': 'session-token',
'INTERNAL_SERVICE_TOKEN': 'bridge-token-from-login',
'BRIDGE_AUTH_TOKEN': 'bridge-token-from-login',
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus',
'user': <String, dynamic>{
'id': 'user-1',
@ -131,6 +137,65 @@ void main() {
},
);
test(
'login sync ignores legacy INTERNAL_SERVICE_TOKEN fallback',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-account-sync-legacy-token-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
await storeRoot.delete(recursive: true);
}
});
final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false,
);
await store.initialize();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
accountBaseUrl: 'https://accounts.svc.plus',
),
);
final controller = SettingsController(
store,
accountClientFactory: (_) => _FakeAccountRuntimeClient(
loginPayload: <String, dynamic>{
'token': 'session-token',
'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token',
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus',
'user': <String, dynamic>{
'id': 'user-1',
'email': 'review@svc.plus',
},
},
),
);
addTearDown(controller.dispose);
await controller.initialize();
await controller.loginAccount(
baseUrl: 'https://accounts.svc.plus',
identifier: 'review@svc.plus',
password: 'password',
);
expect(controller.accountSyncState, isNotNull);
expect(controller.accountSyncState!.syncState, 'blocked');
expect(
await store.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
),
isNull,
);
},
);
test('syncAccountSettings pins the managed bridge cloud entry', () async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-account-managed-bridge-',
@ -160,7 +225,14 @@ void main() {
value: 'bridge-token',
);
final controller = SettingsController(store);
final client = _FakeAccountRuntimeClient(
loginPayload: const <String, dynamic>{},
profilePayload: const <String, dynamic>{},
);
final controller = SettingsController(
store,
accountClientFactory: (_) => client,
);
addTearDown(controller.dispose);
await controller.initialize();
@ -183,8 +255,77 @@ void main() {
.endpoint,
kManagedBridgeServerUrl,
);
expect(client.loadProfileCallCount, 1);
});
test(
'syncAccountSettings refreshes managed bridge contract from protected account profile',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-account-managed-bridge-refresh-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
await storeRoot.delete(recursive: true);
}
});
final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false,
);
await store.initialize();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus',
),
);
await store.saveAccountSessionToken('session-token');
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
value: 'stale-bridge-token',
);
final client = _FakeAccountRuntimeClient(
loginPayload: const <String, dynamic>{},
profilePayload: <String, dynamic>{
'BRIDGE_AUTH_TOKEN': 'fresh-bridge-token',
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-new.svc.plus',
'user': <String, dynamic>{
'id': 'user-1',
'email': 'review@svc.plus',
},
},
);
final controller = SettingsController(
store,
accountClientFactory: (_) => client,
);
addTearDown(controller.dispose);
await controller.initialize();
final result = await controller.syncAccountSettings(
baseUrl: 'https://accounts.svc.plus',
);
expect(result.state, 'ready');
expect(client.loadProfileCallCount, 1);
expect(
await store.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
),
'fresh-bridge-token',
);
expect(
controller.accountSyncState!.syncedDefaults.bridgeServerUrl,
'https://xworkmate-bridge-new.svc.plus',
);
},
);
test(
'does not recover bridge sync state from stale cloud-synced snapshot state',
() async {
@ -243,10 +384,15 @@ void main() {
}
class _FakeAccountRuntimeClient extends AccountRuntimeClient {
_FakeAccountRuntimeClient({required this.loginPayload})
_FakeAccountRuntimeClient({
required this.loginPayload,
this.profilePayload = const <String, dynamic>{},
})
: super(baseUrl: 'https://accounts.svc.plus');
final Map<String, dynamic> loginPayload;
final Map<String, dynamic> profilePayload;
int loadProfileCallCount = 0;
@override
Future<Map<String, dynamic>> login({
@ -255,4 +401,10 @@ class _FakeAccountRuntimeClient extends AccountRuntimeClient {
}) async {
return loginPayload;
}
@override
Future<Map<String, dynamic>> loadProfile({required String token}) async {
loadProfileCallCount += 1;
return profilePayload;
}
}