Refactor assistant page and gateway runtime integration

- Unify execution target switching in app controllers
- Enhance assistant page with gateway-aware message handling
- Add comprehensive tests for execution target switching and gateway runtime
- Integrate gateway settings into settings center

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Haitao Pan 2026-03-22 18:17:24 +08:00
parent 6e88290b98
commit cb1c176b3f
9 changed files with 1022 additions and 125 deletions

View File

@ -0,0 +1,654 @@
XWorkmate App Internal State Architecture
Last Updated: 2026-03-22
Purpose
This document defines the current internal state model of XWorkmate with a
focus on the relationship between:
- Settings center configuration state
- Current assistant session state
- Task thread state
- Skill state
- Execution target / work mode
- Model selection
- Conversation content
This file is intended to be the plain-text baseline for future AI-assisted
changes. If a new implementation conflicts with this document, the change
should be treated as suspicious until the ownership and data flow are clarified.
Scope note
This document is written from the Desktop implementation first, because the
Desktop controller currently owns the richest runtime and persistence path.
Where Web has a parallel implementation with the same state semantics, that
mapping is called out explicitly instead of being treated as an afterthought.
========================================================================
1. Core Rule
========================================================================
There are two primary state layers and one derived UI layer.
Layer A: Settings center configuration state
Layer B: Current assistant session state
Layer C: Derived UI state
The most important rule is:
Settings center state is not the same thing as current session state.
Settings defines defaults and persisted app-level configuration.
Session state defines what the currently selected task thread is actually using.
UI should render from the resolved session state, not from settings alone.
------------------------------------------------------------------------
Architecture Diagram
------------------------------------------------------------------------
```mermaid
graph TB
subgraph P["④ Persistence Layer"]
SettingsStore["SettingsStore"]
SecretStore["SecretStore"]
SecureConfigStore["SecureConfigStore"]
end
subgraph C_D["②a AppControllerDesktop (Desktop)"]
settings["settings<br/>(persisted snapshot)"]
settingsDraft["settingsDraft<br/>(in-memory draft)"]
_draftSecretValues["_draftSecretValues"]
_pendingApply["_pendingSettingsApply<br/>_pendingGatewayApply<br/>_pendingAiGatewayApply"]
_assistantThreadRecords["_assistantThreadRecords[sessionKey]<br/><AssistantThreadRecord>"]
_assistantThreadMessages["_assistantThreadMessages[sessionKey]"]
_gatewayHistoryCache["_gatewayHistoryCache[sessionKey]"]
_localSessionMessages["_localSessionMessages[sessionKey]"]
_aiGatewayStreaming["_aiGatewayStreamingTextBySession[sessionKey]"]
end
subgraph C_W["②b AppControllerWeb (Web, parallel)"]
settings_W["_settings"]
settingsDraft_W["_settingsDraft"]
_threadRecords["_threadRecords[sessionKey]<br/><AssistantThreadRecord>"]
end
subgraph R["③ Resolver / Accessor Layer"]
executionTargetResolver["assistantExecutionTargetForSession(sessionKey)"]
modelResolver["assistantModelForSession(sessionKey)"]
discoveredSkillsR["assistantDiscoveredSkillsForSession(sessionKey)"]
importedSkillsR["assistantImportedSkillsForSession(sessionKey)"]
selectedSkillsR["assistantSelectedSkillKeysForSession(sessionKey)"]
connectionStateR["assistantConnectionStateForSession(sessionKey)"]
end
subgraph U["④ UI Layer (Derived, not authoritative)"]
subgraph AP["AssistantPage"]
_taskSeeds["_taskSeeds<br/>(rendering cache)"]
connectionChip["connection chip"]
execSelector["execution target selector"]
skillPanel["skill panel"]
modelLabel["model label"]
taskList["task list"]
end
subgraph SP["SettingsPage"]
draftEditor["settingsDraft editor"]
saveApply["Save / Apply buttons"]
end
end
%% Persistence writes
SecretStore -->|"secure secrets"| _draftSecretValues
SecureConfigStore -->|"config refs"| settings
SettingsStore -->|"persisted snapshot"| settings
%% Settings draft flow
settingsDraft -.->|"_settingsDraftInitialized<br/>? draft : settings"| settings
_draftSecretValues -->|flush on Apply| SecretStore
_pendingApply -->|Apply triggers| settings
%% Thread record is the per-session state core
_assistantThreadRecords -->|executionTarget<br/>assistantModelId<br/>selectedSkillKeys<br/>discoveredSkills<br/>importedSkills<br/>messageViewMode| R
_assistantThreadMessages -->|gateway-backed messages| R
_gatewayHistoryCache -->|gateway history| R
_localSessionMessages -->|local messages| R
_aiGatewayStreaming -->|streaming text| R
%% Resolver output feeds UI
executionTargetResolver -->|currentAssistantExecutionTarget| execSelector
executionTargetResolver -->|connection state| connectionChip
modelResolver -->|resolved model| modelLabel
discoveredSkillsR -->|discovered skills| skillPanel
importedSkillsR -->|imported skills| skillPanel
selectedSkillsR -->|selected keys| skillPanel
connectionStateR -->|connection state| connectionChip
%% Task list from thread records
_assistantThreadRecords -.->|"title / preview<br/>/ status"| _taskSeeds
_taskSeeds --> taskList
settings -.->|grouping defaults| taskList
%% Settings page drives draft and apply
draftEditor --> settingsDraft
saveApply -->|Save = persist| settingsDraft
saveApply -->|Apply = runtime sync| _assistantThreadRecords
saveApply -->|Apply = runtime sync| settings
%% Web parallel — same schema, isolated instance
C_W -.->|"same AssistantThreadRecord<br/>schema as Desktop"| C_D
style P fill:#e8f4f8,stroke:#4a90a4,stroke-width:1px
style C_D fill:#f0f0f0,stroke:#666,stroke-width:2px
style C_W fill:#f9f9f9,stroke:#999,stroke-width:1px,stroke-dasharray:4
style R fill:#fff8e1,stroke:#f9a825,stroke-width:1px
style U fill:#e8f5e9,stroke:#43a047,stroke-width:1px
```
**How to read this diagram:**
- **Solid arrows** (`-->`) = authoritative data flow / ownership
- **Dashed arrows** (`-.->`) = derived / recomputed read
- **Resolver layer** implements the resolution order: thread record field → settings fallback
- **Web (`AppControllerWeb`)** maintains its own isolated `_threadRecords` instance; it has the same `AssistantThreadRecord` schema but is a separate runtime copy
- **Persistence layer** never flows upward — settings are loaded at bootstrap, drafts are written back on Save, runtime never directly mutates persisted state
========================================================================
2. State Ownership
========================================================================
2.1 Settings center configuration state
Primary owners:
- lib/app/app_controller_desktop.dart
- lib/app/app_controller_web.dart
Primary fields:
- settings
- settingsDraft
- _settingsDraftInitialized
- _draftSecretValues
- _pendingSettingsApply
- _pendingGatewayApply
- _pendingAiGatewayApply
Sources:
- settings
Persisted global snapshot from SettingsController.snapshot
- settingsDraft
In-memory draft used by Settings page before save/apply
- _settingsDraftInitialized
Gate that decides whether settingsDraft should return the in-memory draft or
fall back to the persisted settings snapshot
- _draftSecretValues
Temporary secret draft values before they are persisted into secure storage
Responsibilities:
- Store global default configuration
- Persist app-level settings
- Persist secure secrets
- Make the saved configuration take effect only when Apply is executed
Important APIs:
- saveSettingsDraft(...)
- persistSettingsDraft()
- applySettingsDraft()
- saveSettings(...)
Important rule:
Settings center should define defaults, integrations, and persisted config.
It should not be treated as the only truth for the current task thread.
2.2 Current assistant session state
Primary owners:
- Desktop: lib/app/app_controller_desktop.dart
- Web: lib/app/app_controller_web.dart
Primary in-memory store:
- Desktop:
- _assistantThreadRecords[sessionKey]
- _assistantThreadMessages[sessionKey]
- _gatewayHistoryCache[sessionKey]
- _aiGatewayStreamingTextBySession[sessionKey]
- _localSessionMessages[sessionKey]
- Web:
- _threadRecords[sessionKey]
- _streamingTextBySession[sessionKey]
- current record fallback through _currentRecord
Primary schema:
lib/runtime/runtime_models.dart
AssistantThreadRecord
AssistantThreadRecord fields that matter most:
- executionTarget
- messageViewMode
- discoveredSkills
- importedSkills
- selectedSkillKeys
- assistantModelId
- gatewayEntryState
- messages
Responsibilities:
- Hold per-thread overrides
- Isolate thread behavior from other threads
- Preserve per-thread mode, skills, model, and content
- Allow thread state to differ from global default settings
Important rule:
If a value exists in AssistantThreadRecord for a session, that thread-level
value wins over the global settings default.
Web note:
The Web controller uses the same AssistantThreadRecord schema and the same
basic ownership rule, but the runtime-backed data sources are simpler than
Desktop. In Web, relay/direct-AI conversation state is resolved through
_threadRecords, _currentRecord, and browser/session repositories rather than
Desktop runtime controllers.
2.3 Derived UI state
Primary owners:
- lib/features/assistant/assistant_page.dart
- lib/features/settings/settings_page.dart
Examples:
- task list groups
- top-right connection chip
- bottom execution target selector
- empty-state card
- skill panel
- model label
- task row labels
Responsibilities:
- Display resolved state
- Never become the authoritative source of truth
Important rule:
UI must render from resolved state, not invent its own parallel mode/model/skill
state.
========================================================================
3. Resolution Priority
========================================================================
3.1 Execution target / work mode
Meaning:
- AI Gateway only
- Local OpenClaw Gateway
- Remote OpenClaw Gateway
Primary resolver:
assistantExecutionTargetForSession(sessionKey)
Resolution order:
1. AssistantThreadRecord.executionTarget for that session
2. settings.assistantExecutionTarget
Interpretation:
- settings.assistantExecutionTarget is the default
- thread.executionTarget is the actual current-session override
Consequence:
Changing settings alone does not automatically mean the current thread display
has changed unless the current thread record is also synchronized.
3.2 Model
Primary resolver:
assistantModelForSession(sessionKey)
Resolution order:
1. AssistantThreadRecord.assistantModelId
2. resolved model for current execution target
Fallback rules:
- If target is aiGatewayOnly, use resolvedAiGatewayModel
- If target is local or remote, use resolvedDefaultModel
Interpretation:
Model selection is thread-bound when explicitly set, otherwise inherited from
target-specific defaults.
3.3 Skills
Primary owner:
AssistantThreadRecord
Fields:
- discoveredSkills
- importedSkills
- selectedSkillKeys
Resolution rule:
- The selected/imported/discovered skills shown in UI belong to the current
session thread
- Settings center must not be treated as the source of selected thread skills
3.4 Conversation content
Primary sources:
- _chatController.messages
- _gatewayHistoryCache[sessionKey]
- _assistantThreadMessages[sessionKey]
- _localSessionMessages[sessionKey]
- _aiGatewayStreamingTextBySession[sessionKey]
Resolution rule:
- Gateway-backed thread content and AI-Gateway-only thread content do not come
from the same runtime path
- The UI composes the final visible conversation from multiple stores depending
on the current thread target
3.5 Task thread list
Primary source:
- assistantSessions
- _taskSeeds in AssistantPage as a rendering cache
Important rule:
Task list is a derived representation of thread/session state.
Task list must not become the owner of mode, model, or skill state.
Implementation note:
_taskSeeds is still a cache of derived values such as title, preview, status,
owner, surface, and executionTarget. It is not an authoritative source, but it
can become stale if source mutations do not eventually trigger task
recomputation.
========================================================================
4. Data Flow
========================================================================
4.1 Settings center flow
Edit in Settings page
-> settingsDraft changes
-> Save
-> persisted settings + secure secrets update
-> Apply
-> current configuration takes effect immediately
Meaning of buttons:
- Save
Persist configuration only
Do not trigger runtime connection or model sync
- Apply
Make the current saved configuration take effect immediately
This may connect a gateway, switch execution behavior, or sync AI Gateway
catalog
4.2 Session flow
Select thread
-> switchSession(sessionKey)
-> resolve thread executionTarget
-> resolve thread model
-> resolve thread skills
-> apply thread execution target
-> reload conversation content for the chosen thread
Create new thread
-> inherit current thread executionTarget
-> inherit current thread messageViewMode
-> initialize AssistantThreadRecord
-> switch to the new thread
Change execution target from Assistant page
-> update current thread record.executionTarget
-> optionally persist new global default selection
-> reconnect / disconnect runtime as needed
-> refresh skills and derived UI
========================================================================
5. Dependency Graph
========================================================================
5.1 Settings center depends on
- SettingsController snapshot and secure refs
- SecureConfigStore / SettingsStore / SecretStore
- Runtime side-effect handlers in AppController
5.2 Current assistant session depends on
- _assistantThreadRecords
- runtime snapshot / gateway runtime
- current selected session key
- persisted thread records restored during bootstrap
5.3 Task list depends on
- assistantSessions
- current session key
- thread executionTarget
- per-thread preview / title / status
5.4 Skill panel depends on
- current session key
- assistantImportedSkillsForSession(sessionKey)
- assistantSelectedSkillKeysForSession(sessionKey)
- assistantDiscoveredSkillsForSession(sessionKey)
5.5 Top-right status chip depends on
- current session key
- assistantConnectionStateForSession(currentSessionKey)
- runtime connection snapshot
- session executionTarget
5.6 Bottom execution target selector depends on
- currentAssistantExecutionTarget
- thread executionTarget for current session
5.7 Model label depends on
- assistantModelForSession(currentSessionKey)
- resolvedAiGatewayModel
- resolvedDefaultModel
========================================================================
6. Correct Sync Rules
========================================================================
6.1 What Save should update
Save should update:
- persisted settings snapshot
- persisted secure secrets
- pending apply markers
Save should not update:
- live runtime connection by itself
- current thread execution target by itself
- task list grouping by itself unless the task list is explicitly reading the
global defaults
6.2 What Apply must update when execution behavior changes
If Apply changes assistant execution behavior, it must synchronize:
- settings.assistantExecutionTarget
- current thread AssistantThreadRecord.executionTarget
- runtime connection / disconnection path
- session-specific skill visibility if mode changes
- derived UI:
- top-right chip
- bottom selector
- empty-state card
- task list grouping
6.3 What thread switching must update
switchSession(sessionKey) must synchronize:
- current thread executionTarget
- current thread message view mode
- current thread model
- current thread selected/imported/discovered skills
- current thread conversation content source
- current thread connection label
6.4 What task list must never do
Task list must never:
- own executionTarget
- own model selection
- own selected skills
- become the source of truth for session state
Task list should only display resolved session state.
========================================================================
7. Known Failure Modes
========================================================================
7.1 Settings center and current session diverge
Symptom:
- User saves/applies a new mode in Settings
- Top-right chip still shows old mode
- Bottom selector still shows old mode
Typical cause:
- settings.assistantExecutionTarget updated
- current session AssistantThreadRecord.executionTarget not updated
7.2 Task list grouping is wrong
Symptom:
- Task appears under the wrong mode group
- Group count does not match the visible current thread target
Typical cause:
- task seed / task entry is rendering stale thread executionTarget
- _taskSeeds still holds stale derived values because the mutation path did not
trigger task recomputation
7.3 Skill panel leaks across threads
Symptom:
- A skill selected in one task appears selected in another unrelated task
Typical cause:
- selectedSkillKeys not isolated to AssistantThreadRecord
- UI reading global or shared state instead of current session record
7.4 Model label is stale
Symptom:
- Current thread changed, but header/composer still shows previous model
Typical cause:
- UI not recomputed from assistantModelForSession(currentSessionKey)
7.5 Conversation content source mismatch
Symptom:
- Thread switches, but visible content still reflects previous mode/path
Typical cause:
- current session switched
- content source not switched between gateway-backed history and AI-Gateway-only
cache
========================================================================
8. Canonical Ownership Table
========================================================================
Field: settingsDraft
Owner: Settings center
Scope: global draft
Field: settings
Owner: persisted settings snapshot
Scope: global persisted config
Field: secret drafts
Owner: Settings center
Scope: global draft, secure persistence path
Field: executionTarget
Owner: AssistantThreadRecord first, settings fallback second
Scope: thread
Field: assistantModelId
Owner: AssistantThreadRecord first, target-specific fallback second
Scope: thread
Field: selectedSkillKeys
Owner: AssistantThreadRecord
Scope: thread
Field: importedSkills
Owner: AssistantThreadRecord
Scope: thread
Field: discoveredSkills
Owner: AssistantThreadRecord
Scope: thread
Field: messageViewMode
Owner: AssistantThreadRecord
Scope: thread
Field: conversation messages
Owner: runtime/message stores depending on target
Scope: thread
Field: task list group
Owner: derived UI only
Scope: visual grouping
========================================================================
9. Modification Rules For Future AI Changes
========================================================================
Before changing Assistant, Settings, or Gateway behavior, check:
1. Is this a settings default or a thread override?
2. If a setting is applied, should the current thread record be synchronized?
3. If a thread changes, which derived UI surfaces must refresh?
4. Is the task list only displaying state, or accidentally owning it?
5. Does the change preserve per-thread isolation for:
- mode
- model
- skills
- content
If a proposed change cannot answer those five questions clearly, the
implementation is not ready.
========================================================================
10. Relevant Files
========================================================================
Global settings and apply flow:
- lib/app/app_controller_desktop.dart
- lib/app/app_controller_web.dart
- lib/features/settings/settings_page.dart
Session/thread state:
- lib/runtime/runtime_models.dart
- lib/app/app_controller_desktop.dart
- lib/app/app_controller_web.dart
Assistant UI:
- lib/features/assistant/assistant_page.dart
Persistence:
- lib/runtime/secure_config_store.dart
- lib/runtime/settings_store.dart
- lib/runtime/secret_store.dart
- lib/runtime/legacy_settings_recovery.dart
Supporting architecture docs:
- docs/architecture/assistant-thread-information-architecture.md
- docs/architecture/xworkmate-integrations.md
End of document.

View File

@ -1352,6 +1352,8 @@ class AppController extends ChangeNotifier {
);
if (nextTarget == AssistantExecutionTarget.aiGatewayOnly) {
await discoverGatewayOnlySkillsForSession(nextSessionKey);
} else {
await dismissDiscoveredSkillsForSession(nextSessionKey);
}
_recomputeTasks();
}
@ -1883,8 +1885,8 @@ class AppController extends ChangeNotifier {
_settingsDraftInitialized = true;
_pendingSettingsApply = true;
_settingsDraftStatusMessage = appText(
'已保存设置,等待应用',
'Settings saved. Apply to activate runtime changes.',
'已保存配置,不立即生效',
'Settings saved. They do not take effect until Apply.',
);
notifyListeners();
}
@ -1923,8 +1925,8 @@ class AppController extends ChangeNotifier {
_settingsDraft = settings;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = appText(
'应用全部设置',
'All saved settings have been applied.',
'按当前配置生效',
'The current configuration is now in effect.',
);
notifyListeners();
}
@ -2516,22 +2518,29 @@ class AppController extends ChangeNotifier {
}
Future<void> _applyPersistedGatewaySettings(SettingsSnapshot snapshot) async {
if (snapshot.assistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly) {
if (_runtime.isConnected) {
try {
await disconnectGateway();
} catch (_) {
// Keep saved settings even when runtime teardown is noisy.
}
}
return;
}
try {
await _connectProfile(snapshot.gateway);
} catch (_) {
// Save/apply should keep persisted config even if the immediate
// connection attempt fails.
final target = _sanitizeExecutionTarget(snapshot.assistantExecutionTarget);
final sessionKey = _normalizedAssistantSessionKey(
_sessionsController.currentSessionKey,
);
_upsertAssistantThreadRecord(
sessionKey,
executionTarget: target,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_recomputeTasks();
_notifyIfActive();
await _applyAssistantExecutionTarget(
target,
sessionKey: sessionKey,
persistDefaultSelection: false,
);
if (target == AssistantExecutionTarget.aiGatewayOnly) {
await discoverGatewayOnlySkillsForSession(sessionKey);
} else {
await dismissDiscoveredSkillsForSession(sessionKey);
}
_recomputeTasks();
_notifyIfActive();
}
Future<void> _applyPersistedAiGatewaySettings(

View File

@ -479,8 +479,8 @@ class AppController extends ChangeNotifier {
_settingsDraftInitialized = true;
_pendingSettingsApply = true;
_settingsDraftStatusMessage = appText(
'已保存设置,等待应用',
'Settings saved. Apply to activate runtime changes.',
'已保存配置,不立即生效',
'Settings saved. They do not take effect until Apply.',
);
notifyListeners();
}
@ -501,8 +501,8 @@ class AppController extends ChangeNotifier {
_settingsDraftInitialized = true;
_pendingSettingsApply = false;
_settingsDraftStatusMessage = appText(
'应用全部设置',
'All saved settings have been applied.',
'按当前配置生效',
'The current configuration is now in effect.',
);
notifyListeners();
}

View File

@ -1855,7 +1855,7 @@ class _ConversationArea extends StatelessWidget {
}
}
class _AssistantTaskRail extends StatelessWidget {
class _AssistantTaskRail extends StatefulWidget {
const _AssistantTaskRail({
super.key,
required this.controller,
@ -1883,10 +1883,19 @@ class _AssistantTaskRail extends StatelessWidget {
final Future<void> Function(String sessionKey) onArchiveTask;
final Future<void> Function(_AssistantTaskEntry entry) onRenameTask;
@override
State<_AssistantTaskRail> createState() => _AssistantTaskRailState();
}
class _AssistantTaskRailState extends State<_AssistantTaskRail> {
final Set<AssistantExecutionTarget> _expandedGroups =
<AssistantExecutionTarget>{};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final tasks = widget.tasks;
final groupedTasks = _groupTasksForRail(tasks);
final runningCount = tasks
.where((task) => _normalizedTaskStatus(task.status) == 'running')
@ -1911,16 +1920,16 @@ class _AssistantTaskRail extends StatelessWidget {
Expanded(
child: TextField(
key: const Key('assistant-task-search'),
controller: searchController,
onChanged: onQueryChanged,
controller: widget.searchController,
onChanged: widget.onQueryChanged,
decoration: InputDecoration(
hintText: appText('搜索任务', 'Search tasks'),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: query.isEmpty
suffixIcon: widget.query.isEmpty
? null
: IconButton(
tooltip: appText('清除搜索', 'Clear search'),
onPressed: onClearQuery,
onPressed: widget.onClearQuery,
icon: const Icon(Icons.close_rounded),
),
),
@ -1931,7 +1940,7 @@ class _AssistantTaskRail extends StatelessWidget {
key: const Key('assistant-task-refresh'),
tooltip: appText('刷新任务', 'Refresh tasks'),
onPressed: () async {
await onRefreshTasks();
await widget.onRefreshTasks();
},
icon: const Icon(Icons.refresh_rounded),
),
@ -1943,7 +1952,7 @@ class _AssistantTaskRail extends StatelessWidget {
child: FilledButton.tonalIcon(
key: const Key('assistant-new-task-button'),
onPressed: () async {
await onCreateTask();
await widget.onCreateTask();
},
icon: const Icon(Icons.edit_note_rounded),
label: Text(appText('新对话', 'New conversation')),
@ -1974,7 +1983,7 @@ class _AssistantTaskRail extends StatelessWidget {
),
_MetaPill(
label:
'${appText('技能', 'Skills')} ${controller.skills.length}',
'${appText('技能', 'Skills')} ${widget.controller.skills.length}',
icon: Icons.auto_awesome_rounded,
),
],
@ -2002,68 +2011,75 @@ class _AssistantTaskRail extends StatelessWidget {
),
),
Expanded(
child: tasks.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
appText(
'没有匹配的任务,试试新建一个。',
'No matching tasks. Start a new one.',
),
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: palette.textSecondary,
),
),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
itemCount: groupedTasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final group = groupedTasks[index];
final expanded = _expandedGroups.contains(group.executionTarget);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AssistantTaskGroupHeader(
executionTarget: group.executionTarget,
count: group.items.length,
expanded: expanded,
onTap: () {
setState(() {
if (expanded) {
_expandedGroups.remove(group.executionTarget);
} else {
_expandedGroups.add(group.executionTarget);
}
});
},
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
itemCount: groupedTasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final group = groupedTasks[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AssistantTaskGroupHeader(
executionTarget: group.executionTarget,
count: group.items.length,
),
const SizedBox(height: 4),
for (
var itemIndex = 0;
itemIndex < group.items.length;
itemIndex++
) ...[
if (itemIndex > 0) const SizedBox(height: 4),
_AssistantTaskTile(
entry: group.items[itemIndex],
archiveEnabled:
_normalizedTaskStatus(
group.items[itemIndex].status,
) !=
'running',
onTap: () async {
await onSelectTask(
group.items[itemIndex].sessionKey,
);
},
onRename: () async {
await onRenameTask(group.items[itemIndex]);
},
onArchive: () async {
await onArchiveTask(
group.items[itemIndex].sessionKey,
);
},
if (expanded) ...[
const SizedBox(height: 4),
if (group.items.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(28, 0, 8, 4),
child: Text(
appText('当前分组没有任务。', 'No tasks in this group.'),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
],
],
);
},
),
),
),
for (
var itemIndex = 0;
itemIndex < group.items.length;
itemIndex++
) ...[
if (itemIndex > 0) const SizedBox(height: 4),
_AssistantTaskTile(
entry: group.items[itemIndex],
archiveEnabled:
_normalizedTaskStatus(
group.items[itemIndex].status,
) !=
'running',
onTap: () async {
await widget.onSelectTask(
group.items[itemIndex].sessionKey,
);
},
onRename: () async {
await widget.onRenameTask(group.items[itemIndex]);
},
onArchive: () async {
await widget.onArchiveTask(
group.items[itemIndex].sessionKey,
);
},
),
],
],
],
);
},
),
),
],
),
@ -2086,7 +2102,6 @@ List<_AssistantTaskGroup> _groupTasksForRail(List<_AssistantTaskEntry> tasks) {
items: grouped[target]!,
),
)
.where((group) => group.items.isNotEmpty)
.toList(growable: false);
}
@ -2198,41 +2213,60 @@ class _AssistantTaskGroupHeader extends StatelessWidget {
const _AssistantTaskGroupHeader({
required this.executionTarget,
required this.count,
required this.expanded,
required this.onTap,
});
final AssistantExecutionTarget executionTarget;
final int count;
final bool expanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Padding(
key: ValueKey<String>('assistant-task-group-${executionTarget.name}'),
padding: const EdgeInsets.fromLTRB(4, 2, 4, 0),
child: Row(
children: [
Icon(executionTarget.icon, size: 14, color: palette.textMuted),
const SizedBox(width: 6),
Flexible(
child: Text(
executionTarget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: palette.textSecondary,
fontWeight: FontWeight.w600,
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey<String>('assistant-task-group-${executionTarget.name}'),
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
child: Row(
children: [
Icon(
expanded
? Icons.keyboard_arrow_down_rounded
: Icons.keyboard_arrow_right_rounded,
size: 16,
color: palette.textMuted,
),
),
const SizedBox(width: 4),
Icon(executionTarget.icon, size: 14, color: palette.textMuted),
const SizedBox(width: 6),
Flexible(
child: Text(
executionTarget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: palette.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 6),
Text(
'$count',
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
),
const SizedBox(width: 6),
Text(
'$count',
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
),
),
);
}

View File

@ -378,13 +378,13 @@ class _SettingsPageState extends State<SettingsPage> {
? message
: hasDraft
? appText(
'当前存在未保存草稿。保存会持久化配置,但不会触发连接或模型同步',
'There are unsaved drafts. Save persists settings without connecting or syncing models.',
'当前存在未保存草稿。保存:仅保存配置,不立即生效',
'There are unsaved drafts. Save persists configuration only and does not apply it immediately.',
)
: hasPendingApply
? appText(
'当前存在已保存但未应用的更改。点击应用会触发连接和模型同步',
'There are saved changes waiting to be applied. Apply will trigger connection and model sync.',
'当前存在已保存但未应用的更改。应用:立即按当前配置生效',
'There are saved changes waiting to be applied. Apply makes the current configuration take effect immediately.',
)
: (message.isEmpty
? appText(
@ -933,8 +933,8 @@ class _SettingsPageState extends State<SettingsPage> {
const SizedBox(height: 8),
Text(
appText(
'统一编辑本地 / 远程 OpenClaw Gateway 的连接参数。保存只持久化,应用才会按当前模式发起连接或切换为仅 AI Gateway',
'Edit local and remote OpenClaw gateway settings in one place. Save persists only; Apply connects or switches to AI Gateway-only mode.',
'统一编辑本地 / 远程 OpenClaw Gateway 的连接参数。保存:仅保存配置,不立即生效。应用:立即按当前配置生效',
'Edit local and remote OpenClaw gateway settings in one place. Save persists configuration only and does not apply it immediately. Apply makes the current configuration take effect immediately.',
),
style: theme.textTheme.bodyMedium,
),
@ -2602,7 +2602,6 @@ class _SettingsPageState extends State<SettingsPage> {
final profile = _buildGatewayDraftProfile(settings);
final nextSettings = settings.copyWith(
gateway: profile,
assistantExecutionTarget: _assistantExecutionTargetForMode(profile.mode),
);
await _saveSettings(controller, nextSettings);
if (!mounted) {

View File

@ -199,6 +199,8 @@ class GatewayRuntime extends ChangeNotifier {
fields: connectAuthFields,
sources: connectAuthSources,
);
final usedStoredDeviceTokenOnly =
sharedToken.isEmpty && deviceToken.isNotEmpty;
if (endpoint == null) {
_appendLog(
@ -339,6 +341,20 @@ class GatewayRuntime extends ChangeNotifier {
deviceId: identity.deviceId,
role: 'operator',
);
} else if (usedStoredDeviceTokenOnly &&
_isPairingRequiredError(
runtimeError?.code,
runtimeError?.detailCode,
)) {
await _store.clearDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
);
_appendLog(
'warn',
'auth',
'cleared stale device token after pairing-required response',
);
}
if (!_shouldAutoReconnect(runtimeError)) {
_suppressReconnect = true;
@ -1257,6 +1273,13 @@ class GatewayRuntime extends ChangeNotifier {
return true;
}
bool _isPairingRequiredError(String? code, String? detailCode) {
final resolvedCode = code?.trim().toUpperCase();
final resolvedDetailCode = detailCode?.trim().toUpperCase();
return resolvedCode == 'NOT_PAIRED' ||
resolvedDetailCode == 'PAIRING_REQUIRED';
}
Future<void> _closeSocket() async {
_reconnectTimer?.cancel();
final subscription = _socketSubscription;

View File

@ -62,6 +62,11 @@ void main() {
platform: TargetPlatform.macOS,
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) =>
@ -138,6 +143,11 @@ void main() {
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
);
await tester.pumpAndSettle();
await tester.longPress(
find.byKey(const ValueKey<String>('assistant-task-item-main')),
);
@ -225,6 +235,44 @@ void main() {
);
}, skip: true);
testWidgets('AssistantPage shows three collapsed task groups by default', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-aiGatewayOnly')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-remote')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-item-main')),
findsNothing,
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
);
await _pumpForUiSync(tester);
expect(
find.byKey(const ValueKey<String>('assistant-task-item-main')),
findsOneWidget,
);
});
testWidgets('AssistantPage can switch unified side pane tabs and collapse', (
WidgetTester tester,
) async {
@ -402,9 +450,9 @@ void main() {
);
await _pumpForUiSync(tester);
expect(find.text('仅 AI Gateway'), findsOneWidget);
expect(find.text('仅 AI Gateway'), findsWidgets);
expect(find.text('本地 OpenClaw Gateway'), findsWidgets);
expect(find.text('远程 OpenClaw Gateway'), findsOneWidget);
expect(find.text('远程 OpenClaw Gateway'), findsWidgets);
await tester.tap(find.text('仅 AI Gateway').last);
await _pumpForUiSync(tester);

View File

@ -393,6 +393,78 @@ void main() {
},
);
test(
'AppController applySettingsDraft syncs the active session execution target',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-apply-settings-sync-',
);
addTearDown(() async {
await _deleteDirectoryWithRetry(tempDirectory);
});
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
final gateway = _FakeGatewayRuntime(store: store);
final controller = AppController(
store: store,
runtimeCoordinator: RuntimeCoordinator(
gateway: gateway,
codex: _FakeCodexRuntime(),
),
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.saveSettings(
controller.settings.copyWith(
assistantExecutionTarget: AssistantExecutionTarget.local,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'http://127.0.0.1:11434/v1',
availableModels: const <String>['qwen2.5-coder:latest'],
selectedModels: const <String>['qwen2.5-coder:latest'],
),
defaultModel: 'qwen2.5-coder:latest',
gateway: controller.settings.gateway.copyWith(
mode: RuntimeConnectionMode.remote,
host: 'openclaw.svc.plus',
port: 443,
tls: true,
),
),
refreshAfterSave: false,
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.local,
);
await controller.saveSettingsDraft(
controller.settingsDraft.copyWith(
assistantExecutionTarget: AssistantExecutionTarget.remote,
),
);
await controller.applySettingsDraft();
expect(
controller.currentAssistantExecutionTarget,
AssistantExecutionTarget.remote,
);
expect(
controller.assistantExecutionTargetForSession(controller.currentSessionKey),
AssistantExecutionTarget.remote,
);
expect(
controller.assistantConnectionTargetLabel,
'openclaw.svc.plus:443',
);
expect(gateway.connectedProfiles.last.mode, RuntimeConnectionMode.remote);
},
);
test(
'AppController does not leak the local endpoint into remote thread status while reconnecting',
() async {

View File

@ -212,6 +212,64 @@ void main() {
);
},
);
test(
'GatewayRuntime clears a stale stored device token after NOT_PAIRED',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final identityStore = DeviceIdentityStore(store);
final identity = await identityStore.loadOrCreate();
await store.saveDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
token: 'stale-device-token',
);
final runtime = GatewayRuntime(
store: store,
identityStore: identityStore,
);
final server = await _FakeGatewayRuntimeServer.start(
connectErrorCode: 'NOT_PAIRED',
connectErrorDetailCode: 'PAIRING_REQUIRED',
connectErrorMessage: 'pairing required',
closeAfterConnectError: true,
);
addTearDown(runtime.dispose);
addTearDown(server.close);
await expectLater(
() => runtime.connectProfile(
GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.remote,
host: '127.0.0.1',
port: server.port,
tls: false,
useSetupCode: false,
),
),
throwsA(isA<GatewayRuntimeException>()),
);
expect(server.connectAuth?['token'], 'stale-device-token');
expect(server.connectAuth?['deviceToken'], 'stale-device-token');
expect(
await store.loadDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
),
isNull,
);
expect(
runtime.logs.any(
(entry) =>
entry.category == 'auth' &&
entry.message.contains('cleared stale device token'),
),
isTrue,
);
},
);
}
class _FakeGatewayRuntimeServer {