From cb1c176b3fd8647a910aaf0a6746b5c13c3d4b3e Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 22 Mar 2026 18:17:24 +0800 Subject: [PATCH] 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 --- .../xworkmate-internal-state-architecture.md | 654 ++++++++++++++++++ lib/app/app_controller_desktop.dart | 47 +- lib/app/app_controller_web.dart | 8 +- lib/features/assistant/assistant_page.dart | 220 +++--- lib/features/settings/settings_page.dart | 13 +- lib/runtime/gateway_runtime.dart | 23 + test/features/assistant_page_suite.dart | 52 +- ...troller_execution_target_switch_suite.dart | 72 ++ test/runtime/gateway_runtime_suite.dart | 58 ++ 9 files changed, 1022 insertions(+), 125 deletions(-) create mode 100644 docs/architecture/xworkmate-internal-state-architecture.md diff --git a/docs/architecture/xworkmate-internal-state-architecture.md b/docs/architecture/xworkmate-internal-state-architecture.md new file mode 100644 index 00000000..9c5e93b5 --- /dev/null +++ b/docs/architecture/xworkmate-internal-state-architecture.md @@ -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
(persisted snapshot)"] + settingsDraft["settingsDraft
(in-memory draft)"] + _draftSecretValues["_draftSecretValues"] + _pendingApply["_pendingSettingsApply
_pendingGatewayApply
_pendingAiGatewayApply"] + _assistantThreadRecords["_assistantThreadRecords[sessionKey]
"] + _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]
"] + 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
(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
? draft : settings"| settings + _draftSecretValues -->|flush on Apply| SecretStore + _pendingApply -->|Apply triggers| settings + + %% Thread record is the per-session state core + _assistantThreadRecords -->|executionTarget
assistantModelId
selectedSkillKeys
discoveredSkills
importedSkills
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
/ 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
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. diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 62640217..be700731 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -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 _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 _applyPersistedAiGatewaySettings( diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index 7e4397ab..416cf90e 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -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(); } diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 45823a95..944d4306 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -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 Function(String sessionKey) onArchiveTask; final Future Function(_AssistantTaskEntry entry) onRenameTask; + @override + State<_AssistantTaskRail> createState() => _AssistantTaskRailState(); +} + +class _AssistantTaskRailState extends State<_AssistantTaskRail> { + final Set _expandedGroups = + {}; + @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('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('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, - ), - ), - ], + ), ), ); } diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 66bc7ffd..ddb7d310 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -378,13 +378,13 @@ class _SettingsPageState extends State { ? 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 { 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 { final profile = _buildGatewayDraftProfile(settings); final nextSettings = settings.copyWith( gateway: profile, - assistantExecutionTarget: _assistantExecutionTargetForMode(profile.mode), ); await _saveSettings(controller, nextSettings); if (!mounted) { diff --git a/lib/runtime/gateway_runtime.dart b/lib/runtime/gateway_runtime.dart index 7bd800f0..27664c5b 100644 --- a/lib/runtime/gateway_runtime.dart +++ b/lib/runtime/gateway_runtime.dart @@ -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 _closeSocket() async { _reconnectTimer?.cancel(); final subscription = _socketSubscription; diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index 580b9d55..06f4214d 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -62,6 +62,11 @@ void main() { platform: TargetPlatform.macOS, ); + await tester.tap( + find.byKey(const ValueKey('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('assistant-task-group-local')), + ); + await tester.pumpAndSettle(); + await tester.longPress( find.byKey(const ValueKey('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('assistant-task-group-aiGatewayOnly')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('assistant-task-group-local')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('assistant-task-group-remote')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('assistant-task-item-main')), + findsNothing, + ); + + await tester.tap( + find.byKey(const ValueKey('assistant-task-group-local')), + ); + await _pumpForUiSync(tester); + + expect( + find.byKey(const ValueKey('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); diff --git a/test/runtime/app_controller_execution_target_switch_suite.dart b/test/runtime/app_controller_execution_target_switch_suite.dart index 74014945..e858f7da 100644 --- a/test/runtime/app_controller_execution_target_switch_suite.dart +++ b/test/runtime/app_controller_execution_target_switch_suite.dart @@ -393,6 +393,78 @@ void main() { }, ); + test( + 'AppController applySettingsDraft syncs the active session execution target', + () async { + SharedPreferences.setMockInitialValues({}); + 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 ['qwen2.5-coder:latest'], + selectedModels: const ['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 { diff --git a/test/runtime/gateway_runtime_suite.dart b/test/runtime/gateway_runtime_suite.dart index 789365cb..3c19ad8c 100644 --- a/test/runtime/gateway_runtime_suite.dart +++ b/test/runtime/gateway_runtime_suite.dart @@ -212,6 +212,64 @@ void main() { ); }, ); + + test( + 'GatewayRuntime clears a stale stored device token after NOT_PAIRED', + () async { + SharedPreferences.setMockInitialValues({}); + 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()), + ); + + 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 {