diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 0c28a316..121a326b 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -177,6 +177,7 @@ class AppController extends ChangeNotifier { List get sessions => isAiGatewayOnlyMode ? _assistantSessionSummaries() : _sessionsController.sessions; + List get assistantSessions => _assistantSessions(); List get instances => _instancesController.items; List get skills => _skillsController.items; List get connectors => _connectorsController.items; @@ -189,7 +190,11 @@ class AppController extends ChangeNotifier { String? get activeRunId => _chatController.activeRunId; AppLanguage get appLanguage => settings.appLanguage; AssistantExecutionTarget get assistantExecutionTarget => - settings.assistantExecutionTarget; + currentAssistantExecutionTarget; + AssistantExecutionTarget get currentAssistantExecutionTarget => + assistantExecutionTargetForSession(currentSessionKey); + AssistantMessageViewMode get currentAssistantMessageViewMode => + assistantMessageViewModeForSession(currentSessionKey); AssistantPermissionLevel get assistantPermissionLevel => settings.assistantPermissionLevel; bool get hasStoredGatewayCredential => @@ -206,8 +211,7 @@ class AppController extends ChangeNotifier { bool get hasStoredAiGatewayApiKey => _settingsController.secureRefs.containsKey('ai_gateway_api_key'); bool get isAiGatewayOnlyMode => - settings.assistantExecutionTarget == - AssistantExecutionTarget.aiGatewayOnly; + currentAssistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly; bool get isCodexBridgeBusy => _isCodexBridgeBusy; String? get codexBridgeError => _codexBridgeError; String? get codexRuntimeWarning => _codexRuntimeWarning; @@ -265,7 +269,11 @@ class AppController extends ChangeNotifier { } String get resolvedAssistantModel { - if (isAiGatewayOnlyMode) { + return _resolvedAssistantModelForTarget(currentAssistantExecutionTarget); + } + + String _resolvedAssistantModelForTarget(AssistantExecutionTarget target) { + if (target == AssistantExecutionTarget.aiGatewayOnly) { return resolvedAiGatewayModel; } final resolved = resolvedDefaultModel.trim(); @@ -621,9 +629,71 @@ class AppController extends ChangeNotifier { return trimmed.isEmpty ? 'main' : trimmed; } + AssistantExecutionTarget assistantExecutionTargetForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return _assistantThreadRecords[normalizedSessionKey]?.executionTarget ?? + settings.assistantExecutionTarget; + } + + AssistantMessageViewMode assistantMessageViewModeForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return _assistantThreadRecords[normalizedSessionKey]?.messageViewMode ?? + AssistantMessageViewMode.rendered; + } + + List _assistantSessions() { + final archivedKeys = settings.assistantArchivedTaskKeys + .map(_normalizedAssistantSessionKey) + .toSet(); + final byKey = {}; + + for (final session in _sessionsController.sessions) { + final normalizedSessionKey = _normalizedAssistantSessionKey(session.key); + if (archivedKeys.contains(normalizedSessionKey)) { + continue; + } + byKey[normalizedSessionKey] = session; + } + + for (final record in _assistantThreadRecords.values) { + final normalizedSessionKey = _normalizedAssistantSessionKey( + record.sessionKey, + ); + if (normalizedSessionKey.isEmpty || + archivedKeys.contains(normalizedSessionKey) || + record.archived) { + continue; + } + byKey.putIfAbsent( + normalizedSessionKey, + () => _assistantSessionSummaryFor( + normalizedSessionKey, + record: record, + ), + ); + } + + final currentKey = _normalizedAssistantSessionKey(currentSessionKey); + if (!archivedKeys.contains(currentKey) && !byKey.containsKey(currentKey)) { + byKey[currentKey] = _assistantSessionSummaryFor(currentKey); + } + + final items = byKey.values.toList(growable: true) + ..sort( + (left, right) => + (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0), + ); + return items; + } + bool assistantSessionHasPendingRun(String sessionKey) { final normalized = _normalizedAssistantSessionKey(sessionKey); - if (isAiGatewayOnlyMode) { + if (assistantExecutionTargetForSession(normalized) == + AssistantExecutionTarget.aiGatewayOnly) { return _aiGatewayPendingSessionKeys.contains(normalized); } return (_chatController.hasPendingRun || _multiAgentRunPending) && @@ -735,20 +805,25 @@ class AppController extends ChangeNotifier { tls: decoded?.tls ?? settings.gateway.tls, mode: _modeFromHost(decoded?.host ?? settings.gateway.host), ); + final nextTarget = _assistantExecutionTargetForMode(nextProfile.mode); await saveSettings( settings.copyWith( gateway: nextProfile, - assistantExecutionTarget: _assistantExecutionTargetForMode( - nextProfile.mode, - ), + assistantExecutionTarget: nextTarget, ), refreshAfterSave: false, ); + _upsertAssistantThreadRecord( + _sessionsController.currentSessionKey, + executionTarget: nextTarget, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); await _connectProfile( nextProfile, authTokenOverride: resolvedToken, authPasswordOverride: resolvedPassword, ); + await _chatController.loadSession(_sessionsController.currentSessionKey); } Future connectManual({ @@ -778,20 +853,25 @@ class AppController extends ChangeNotifier { port: resolvedPort <= 0 ? 443 : resolvedPort, tls: mode == RuntimeConnectionMode.local ? false : tls, ); + final nextTarget = _assistantExecutionTargetForMode(nextProfile.mode); await saveSettings( settings.copyWith( gateway: nextProfile, - assistantExecutionTarget: _assistantExecutionTargetForMode( - nextProfile.mode, - ), + assistantExecutionTarget: nextTarget, ), refreshAfterSave: false, ); + _upsertAssistantThreadRecord( + _sessionsController.currentSessionKey, + executionTarget: nextTarget, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); await _connectProfile( nextProfile, authTokenOverride: token.trim(), authPasswordOverride: password.trim(), ); + await _chatController.loadSession(_sessionsController.currentSessionKey); } Future disconnectGateway() async { @@ -916,8 +996,29 @@ class AppController extends ChangeNotifier { } Future switchSession(String sessionKey) async { - await _sessionsController.switchSession(sessionKey); - await _chatController.loadSession(_sessionsController.currentSessionKey); + final previousSessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + final nextSessionKey = _normalizedAssistantSessionKey(sessionKey); + final nextTarget = assistantExecutionTargetForSession(nextSessionKey); + final nextViewMode = assistantMessageViewModeForSession(nextSessionKey); + + if (!isAiGatewayOnlyMode) { + _preserveGatewayHistoryForSession(previousSessionKey); + } + + await _sessionsController.switchSession(nextSessionKey); + _upsertAssistantThreadRecord( + nextSessionKey, + executionTarget: nextTarget, + messageViewMode: nextViewMode, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _applyAssistantExecutionTarget( + nextTarget, + sessionKey: nextSessionKey, + persistDefaultSelection: false, + ); _recomputeTasks(); } @@ -974,46 +1075,43 @@ class AppController extends ChangeNotifier { Future setAssistantExecutionTarget( AssistantExecutionTarget target, ) async { - if (settings.assistantExecutionTarget == target) { - return; - } - if (target == AssistantExecutionTarget.aiGatewayOnly) { - _preserveGatewayHistoryForSession(_sessionsController.currentSessionKey); - final nextGatewayProfile = settings.gateway.copyWith( - mode: RuntimeConnectionMode.unconfigured, - useSetupCode: false, - setupCode: '', - ); - await saveSettings( - settings.copyWith( - assistantExecutionTarget: target, - gateway: nextGatewayProfile, - ), - refreshAfterSave: false, - ); - await _ensureActiveAssistantThread(); - if (_runtime.isConnected) { - try { - await disconnectGateway(); - } catch (_) { - // Preserve the selected AI Gateway-only mode even if the active - // gateway session does not close cleanly on the first attempt. - } - } - return; - } - - await saveSettings( - settings.copyWith(assistantExecutionTarget: target), - refreshAfterSave: false, + final currentTarget = assistantExecutionTargetForSession( + _sessionsController.currentSessionKey, ); - final targetProfile = _gatewayProfileForAssistantExecutionTarget(target); - try { - await _connectProfile(targetProfile); - } catch (_) { - // Keep the selected execution target even when the immediate reconnect - // fails so the user can retry or adjust gateway settings manually. + if (currentTarget == target && + settings.assistantExecutionTarget == target) { + return; } + _upsertAssistantThreadRecord( + _sessionsController.currentSessionKey, + executionTarget: target, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _applyAssistantExecutionTarget( + target, + sessionKey: _sessionsController.currentSessionKey, + persistDefaultSelection: true, + ); + _recomputeTasks(); + _notifyIfActive(); + } + + Future setAssistantMessageViewMode( + AssistantMessageViewMode mode, + ) async { + final sessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + if (assistantMessageViewModeForSession(sessionKey) == mode) { + return; + } + _upsertAssistantThreadRecord( + sessionKey, + messageViewMode: mode, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _recomputeTasks(); + _notifyIfActive(); } Future setAssistantPermissionLevel( @@ -1028,6 +1126,56 @@ class AppController extends ChangeNotifier { ); } + Future _applyAssistantExecutionTarget( + AssistantExecutionTarget target, { + required String sessionKey, + required bool persistDefaultSelection, + }) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (!matchesSessionKey( + normalizedSessionKey, + _sessionsController.currentSessionKey, + )) { + await _sessionsController.switchSession(normalizedSessionKey); + } + if (persistDefaultSelection && + settings.assistantExecutionTarget != target) { + await saveSettings( + settings.copyWith(assistantExecutionTarget: target), + refreshAfterSave: false, + ); + } + + if (target == AssistantExecutionTarget.aiGatewayOnly) { + if (_runtime.isConnected) { + _preserveGatewayHistoryForSession(normalizedSessionKey); + } + await _ensureActiveAssistantThread(); + if (_runtime.isConnected) { + try { + await disconnectGateway(); + } catch (_) { + // Preserve the selected thread-bound target even when the active + // gateway session does not close cleanly on the first attempt. + } + } else { + _chatController.clear(); + } + await _sessionsController.switchSession(normalizedSessionKey); + return; + } + + final targetProfile = _gatewayProfileForAssistantExecutionTarget(target); + try { + await _connectProfile(targetProfile); + } catch (_) { + // Keep the selected execution target even when the immediate reconnect + // fails so the user can retry or adjust gateway settings manually. + } + await _sessionsController.switchSession(normalizedSessionKey); + await _chatController.loadSession(normalizedSessionKey); + } + Future selectDefaultModel(String modelId) async { final trimmed = modelId.trim(); if (trimmed.isEmpty || settings.defaultModel == trimmed) { @@ -1061,6 +1209,24 @@ class AppController extends ChangeNotifier { return _assistantThreadRecords[normalizedSessionKey]?.title.trim() ?? ''; } + void initializeAssistantThreadContext( + String sessionKey, { + String title = '', + AssistantExecutionTarget? executionTarget, + AssistantMessageViewMode? messageViewMode, + }) { + _upsertAssistantThreadRecord( + sessionKey, + title: title.trim(), + executionTarget: + executionTarget ?? assistantExecutionTargetForSession(currentSessionKey), + messageViewMode: + messageViewMode ?? assistantMessageViewModeForSession(currentSessionKey), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _notifyIfActive(); + } + Future saveAssistantTaskTitle(String sessionKey, String title) async { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); if (normalizedSessionKey.isEmpty) { @@ -2110,7 +2276,9 @@ class AppController extends ChangeNotifier { inputTokens: null, outputTokens: null, totalTokens: null, - model: resolvedAssistantModel, + model: _resolvedAssistantModelForTarget( + assistantExecutionTargetForSession(normalizedSessionKey), + ), contextTokens: null, derivedTitle: title.isEmpty ? null : title, lastMessagePreview: preview, @@ -2149,6 +2317,9 @@ class AppController extends ChangeNotifier { ? record.title.trim() : titleFromSettings, archived: record.archived || archivedKeys.contains(sessionKey), + executionTarget: + record.executionTarget ?? settings.assistantExecutionTarget, + messageViewMode: record.messageViewMode, ); _assistantThreadRecords[sessionKey] = normalizedRecord; if (normalizedRecord.messages.isNotEmpty) { @@ -2165,6 +2336,8 @@ class AppController extends ChangeNotifier { double? updatedAtMs, String? title, bool? archived, + AssistantExecutionTarget? executionTarget, + AssistantMessageViewMode? messageViewMode, }) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); final existing = _assistantThreadRecords[normalizedSessionKey]; @@ -2185,6 +2358,14 @@ class AppController extends ChangeNotifier { archived ?? existing?.archived ?? isAssistantTaskArchived(normalizedSessionKey), + executionTarget: + executionTarget ?? + existing?.executionTarget ?? + settings.assistantExecutionTarget, + messageViewMode: + messageViewMode ?? + existing?.messageViewMode ?? + AssistantMessageViewMode.rendered, ); _assistantThreadRecords[normalizedSessionKey] = nextRecord; if (messages != null) { @@ -2504,7 +2685,7 @@ class AppController extends ChangeNotifier { return CodeAgentNodeState( selectedAgentId: _agentsController.selectedAgentId, gatewayConnected: _runtime.isConnected, - executionTarget: settings.assistantExecutionTarget, + executionTarget: currentAssistantExecutionTarget, runtimeMode: effectiveCodeAgentRuntimeMode, bridgeEnabled: _isCodexBridgeEnabled, bridgeState: _codexCooperationState.name, diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 939fd3a3..d80e009a 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; import '../../app/app_controller.dart'; import '../../app/app_metadata.dart'; @@ -368,12 +370,15 @@ class _AssistantPageState extends State { controller: controller, currentTask: currentTask, items: timelineItems, + messageViewMode: controller.currentAssistantMessageViewMode, scrollController: _conversationController, onOpenDetail: widget.onOpenDetail, onFocusComposer: _focusComposer, onOpenGateway: _showConnectDialog, onOpenAiGatewaySettings: _openAiGatewaySettings, onReconnectGateway: _connectFromSavedSettingsOrShowDialog, + onMessageViewModeChanged: + controller.setAssistantMessageViewMode, ), ), const SizedBox(height: 2), @@ -547,14 +552,14 @@ class _AssistantPageState extends State { Future _submitPrompt() async { final controller = widget.controller; final settings = controller.settings; + final executionTarget = controller.assistantExecutionTarget; final rawPrompt = _inputController.text.trim(); if (rawPrompt.isEmpty) { return; } final shouldUseGatewayAgent = - settings.assistantExecutionTarget != - AssistantExecutionTarget.aiGatewayOnly; + executionTarget != AssistantExecutionTarget.aiGatewayOnly; final autoAgent = shouldUseGatewayAgent ? _pickAutoAgent(controller, rawPrompt) : null; @@ -571,7 +576,7 @@ class _AssistantPageState extends State { prompt: rawPrompt, attachmentNames: attachmentNames, selectedSkillLabels: selectedSkillLabels, - executionTarget: settings.assistantExecutionTarget, + executionTarget: executionTarget, permissionLevel: settings.assistantPermissionLevel, workspacePath: settings.workspacePath, remoteProjectRoot: settings.remoteProjectRoot, @@ -591,15 +596,14 @@ class _AssistantPageState extends State { preview: rawPrompt, status: controller.hasAssistantPendingRun || - settings.assistantExecutionTarget == - AssistantExecutionTarget.aiGatewayOnly || + executionTarget == AssistantExecutionTarget.aiGatewayOnly || controller.connection.status == RuntimeConnectionStatus.connected ? 'running' : 'queued', owner: autoAgent?.name ?? _conversationOwnerLabel(controller), surface: 'Assistant', - executionTarget: settings.assistantExecutionTarget, + executionTarget: executionTarget, draft: controller.currentSessionKey.trim().startsWith('draft:'), ); }); @@ -824,6 +828,9 @@ class _AssistantPageState extends State { Future _createNewThread() async { final sessionKey = _buildDraftSessionKey(widget.controller); + final inheritedTarget = widget.controller.currentAssistantExecutionTarget; + final inheritedViewMode = + widget.controller.currentAssistantMessageViewMode; setState(() { _archivedTaskKeys.removeWhere( (value) => _sessionKeysMatch(value, sessionKey), @@ -839,11 +846,17 @@ class _AssistantPageState extends State { updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), owner: _conversationOwnerLabel(widget.controller), surface: 'Assistant', - executionTarget: widget.controller.assistantExecutionTarget, + executionTarget: inheritedTarget, draft: true, ); _selectedSkillKeys = const []; }); + widget.controller.initializeAssistantThreadContext( + sessionKey, + title: appText('新对话', 'New conversation'), + executionTarget: inheritedTarget, + messageViewMode: inheritedViewMode, + ); await widget.controller.switchSession(sessionKey); _focusComposer(); } @@ -908,18 +921,17 @@ class _AssistantPageState extends State { updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), owner: _conversationOwnerLabel(widget.controller), surface: 'Assistant', - executionTarget: widget.controller.assistantExecutionTarget, + executionTarget: widget.controller.currentAssistantExecutionTarget, isCurrent: true, draft: true, ); } void _synchronizeTaskSeeds(AppController controller) { - for (final session in controller.sessions) { + for (final session in controller.assistantSessions) { if (_isArchivedTask(session.key)) { continue; } - final existingSeed = _taskSeeds[session.key]; _taskSeeds[session.key] = _AssistantTaskSeed( sessionKey: session.key, title: _resolvedTaskTitle(controller, session.key, session: session), @@ -935,9 +947,9 @@ class _AssistantPageState extends State { DateTime.now().millisecondsSinceEpoch.toDouble(), owner: _conversationOwnerLabel(controller), surface: session.surface ?? session.kind ?? 'Assistant', - executionTarget: - existingSeed?.executionTarget ?? - controller.assistantExecutionTarget, + executionTarget: controller.assistantExecutionTargetForSession( + session.key, + ), draft: session.key.trim().startsWith('draft:'), ); } @@ -970,8 +982,9 @@ class _AssistantPageState extends State { updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), owner: _conversationOwnerLabel(controller), surface: currentSeed?.surface ?? 'Assistant', - executionTarget: - currentSeed?.executionTarget ?? controller.assistantExecutionTarget, + executionTarget: controller.assistantExecutionTargetForSession( + controller.currentSessionKey, + ), draft: controller.currentSessionKey.trim().startsWith('draft:'), ); } @@ -980,7 +993,7 @@ class _AssistantPageState extends State { AppController controller, String sessionKey, ) { - for (final session in controller.sessions) { + for (final session in controller.assistantSessions) { if (_sessionKeysMatch(session.key, sessionKey)) { return session; } @@ -1558,23 +1571,28 @@ class _ConversationArea extends StatelessWidget { required this.controller, required this.currentTask, required this.items, + required this.messageViewMode, required this.scrollController, required this.onOpenDetail, required this.onFocusComposer, required this.onOpenGateway, required this.onOpenAiGatewaySettings, required this.onReconnectGateway, + required this.onMessageViewModeChanged, }); final AppController controller; final _AssistantTaskEntry currentTask; final List<_TimelineItem> items; + final AssistantMessageViewMode messageViewMode; final ScrollController scrollController; final ValueChanged onOpenDetail; final VoidCallback onFocusComposer; final VoidCallback onOpenGateway; final VoidCallback onOpenAiGatewaySettings; final Future Function() onReconnectGateway; + final Future Function(AssistantMessageViewMode mode) + onMessageViewModeChanged; @override Widget build(BuildContext context) { @@ -1634,7 +1652,17 @@ class _ConversationArea extends StatelessWidget { ), ), const SizedBox(width: 8), - _ConnectionChip(controller: controller), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _MessageViewModeChip( + value: messageViewMode, + onSelected: onMessageViewModeChanged, + ), + const SizedBox(width: 6), + _ConnectionChip(controller: controller), + ], + ), ], ), ), @@ -1664,18 +1692,21 @@ class _ConversationArea extends StatelessWidget { text: item.text!, alignRight: true, tone: _BubbleTone.user, + messageViewMode: messageViewMode, ), _TimelineItemKind.assistant => _MessageBubble( label: item.label!, text: item.text!, alignRight: false, tone: _BubbleTone.assistant, + messageViewMode: messageViewMode, ), _TimelineItemKind.agent => _MessageBubble( label: item.label!, text: item.text!, alignRight: false, tone: _BubbleTone.agent, + messageViewMode: messageViewMode, ), _TimelineItemKind.toolCall => _ToolCallTile( toolName: item.title!, @@ -2964,12 +2995,14 @@ class _MessageBubble extends StatelessWidget { required this.text, required this.alignRight, required this.tone, + required this.messageViewMode, }); final String label; final String text; final bool alignRight; final _BubbleTone tone; + final AssistantMessageViewMode messageViewMode; @override Widget build(BuildContext context) { @@ -3003,12 +3036,11 @@ class _MessageBubble extends StatelessWidget { children: [ Text(label, style: theme.textTheme.labelLarge), const SizedBox(height: 4), - SelectableText( - text.isEmpty ? appText('暂无内容。', 'No content yet.') : text, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurface, - height: 1.45, - ), + _MessageBubbleBody( + text: text.isEmpty ? appText('暂无内容。', 'No content yet.') : text, + renderMarkdown: + messageViewMode == AssistantMessageViewMode.rendered && + tone != _BubbleTone.user, ), ], ), @@ -3018,6 +3050,74 @@ class _MessageBubble extends StatelessWidget { } } +class _MessageBubbleBody extends StatelessWidget { + const _MessageBubbleBody({ + required this.text, + required this.renderMarkdown, + }); + + final String text; + final bool renderMarkdown; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (!renderMarkdown) { + return SelectableText( + text, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + height: 1.45, + ), + ); + } + + final styleSheet = MarkdownStyleSheet.fromTheme(theme).copyWith( + p: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + height: 1.45, + ), + code: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'Menlo', + height: 1.4, + ), + codeblockDecoration: BoxDecoration( + color: context.palette.surfaceSecondary, + borderRadius: BorderRadius.circular(10), + ), + blockquoteDecoration: BoxDecoration( + color: context.palette.surfaceSecondary.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(10), + ), + blockquotePadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + tableBorder: TableBorder.all(color: context.palette.strokeSoft), + tableHead: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ); + + return MarkdownBody( + data: text, + selectable: true, + styleSheet: styleSheet, + extensionSet: md.ExtensionSet.gitHubWeb, + sizedImageBuilder: (config) => SelectableText( + config.alt?.trim().isNotEmpty == true + ? '![${config.alt!.trim()}](${config.uri.toString()})' + : config.uri.toString(), + style: theme.textTheme.bodyMedium?.copyWith( + color: context.palette.textSecondary, + height: 1.4, + ), + ), + onTapLink: (text, href, title) {}, + ); + } +} + class _TaskStatusCard extends StatelessWidget { const _TaskStatusCard({ required this.title, @@ -3423,6 +3523,72 @@ class _ConnectionChip extends StatelessWidget { } } +class _MessageViewModeChip extends StatelessWidget { + const _MessageViewModeChip({ + required this.value, + required this.onSelected, + }); + + final AssistantMessageViewMode value; + final Future Function(AssistantMessageViewMode mode) onSelected; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + + return PopupMenuButton( + key: const Key('assistant-message-view-mode-button'), + tooltip: appText('消息视图', 'Message view'), + onSelected: (mode) => unawaited(onSelected(mode)), + itemBuilder: (context) => AssistantMessageViewMode.values + .map( + (mode) => PopupMenuItem( + value: mode, + child: Row( + children: [ + Expanded(child: Text(mode.label)), + if (mode == value) const Icon(Icons.check_rounded, size: 18), + ], + ), + ), + ) + .toList(growable: false), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: 5, + ), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.chip), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.03), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.notes_rounded, size: 14, color: palette.textMuted), + const SizedBox(width: 4), + Text(value.label, style: theme.textTheme.labelMedium), + const SizedBox(width: 2), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 14, + color: palette.textMuted, + ), + ], + ), + ), + ); + } +} + enum _BubbleTone { user, assistant, agent } enum _TimelineItemKind { user, assistant, agent, taskCard, toolCall } diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 696e577f..40d4167d 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -63,6 +63,22 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget { } } +enum AssistantMessageViewMode { rendered, raw } + +extension AssistantMessageViewModeCopy on AssistantMessageViewMode { + String get label => switch (this) { + AssistantMessageViewMode.rendered => appText('渲染', 'Rendered'), + AssistantMessageViewMode.raw => 'RAW', + }; + + static AssistantMessageViewMode fromJsonValue(String? value) { + return AssistantMessageViewMode.values.firstWhere( + (item) => item.name == value, + orElse: () => AssistantMessageViewMode.rendered, + ); + } +} + enum AssistantPermissionLevel { defaultAccess, fullAccess } extension AssistantPermissionLevelCopy on AssistantPermissionLevel { @@ -1602,6 +1618,8 @@ class AssistantThreadRecord { required this.updatedAtMs, required this.title, required this.archived, + required this.executionTarget, + required this.messageViewMode, }); final String sessionKey; @@ -1609,6 +1627,8 @@ class AssistantThreadRecord { final double? updatedAtMs; final String title; final bool archived; + final AssistantExecutionTarget? executionTarget; + final AssistantMessageViewMode messageViewMode; AssistantThreadRecord copyWith({ String? sessionKey, @@ -1616,6 +1636,9 @@ class AssistantThreadRecord { double? updatedAtMs, String? title, bool? archived, + AssistantExecutionTarget? executionTarget, + bool clearExecutionTarget = false, + AssistantMessageViewMode? messageViewMode, }) { return AssistantThreadRecord( sessionKey: sessionKey ?? this.sessionKey, @@ -1623,6 +1646,10 @@ class AssistantThreadRecord { updatedAtMs: updatedAtMs ?? this.updatedAtMs, title: title ?? this.title, archived: archived ?? this.archived, + executionTarget: clearExecutionTarget + ? null + : (executionTarget ?? this.executionTarget), + messageViewMode: messageViewMode ?? this.messageViewMode, ); } @@ -1633,6 +1660,8 @@ class AssistantThreadRecord { 'updatedAtMs': updatedAtMs, 'title': title, 'archived': archived, + 'executionTarget': executionTarget?.name, + 'messageViewMode': messageViewMode.name, }; } @@ -1661,6 +1690,14 @@ class AssistantThreadRecord { updatedAtMs: asDouble(json['updatedAtMs']), title: json['title']?.toString() ?? '', archived: json['archived'] as bool? ?? false, + executionTarget: json['executionTarget'] == null + ? null + : AssistantExecutionTargetCopy.fromJsonValue( + json['executionTarget']?.toString(), + ), + messageViewMode: AssistantMessageViewModeCopy.fromJsonValue( + json['messageViewMode']?.toString(), + ), ); } } diff --git a/lib/widgets/gateway_connect_dialog.dart b/lib/widgets/gateway_connect_dialog.dart index 9f75edfd..899878c8 100644 --- a/lib/widgets/gateway_connect_dialog.dart +++ b/lib/widgets/gateway_connect_dialog.dart @@ -68,12 +68,20 @@ class _GatewayConnectDialogState extends State { void initState() { super.initState(); final profile = widget.controller.settings.gateway; + final executionTarget = widget.controller.currentAssistantExecutionTarget; _setupCodeController = TextEditingController(text: profile.setupCode); _hostController = TextEditingController(text: profile.host); _portController = TextEditingController(text: '${profile.port}'); _tls = profile.tls; - _connectionMode = profile.mode; - _mode = profile.useSetupCode ? 'setup' : 'manual'; + _connectionMode = switch (executionTarget) { + AssistantExecutionTarget.aiGatewayOnly => + RuntimeConnectionMode.unconfigured, + AssistantExecutionTarget.local => RuntimeConnectionMode.local, + AssistantExecutionTarget.remote => RuntimeConnectionMode.remote, + }; + _mode = executionTarget == AssistantExecutionTarget.aiGatewayOnly + ? 'manual' + : (profile.useSetupCode ? 'setup' : 'manual'); _loadBootstrapPrefill(); } @@ -428,32 +436,9 @@ class _GatewayConnectDialogState extends State { password: _passwordController.text, ); } else if (_connectionMode == RuntimeConnectionMode.unconfigured) { - final currentSettings = widget.controller.settings; - final currentProfile = currentSettings.gateway; - final resolvedHost = _hostController.text.trim().isEmpty - ? currentProfile.host - : _hostController.text.trim(); - final resolvedPort = - int.tryParse(_portController.text.trim()) ?? currentProfile.port; - final nextProfile = currentProfile.copyWith( - mode: RuntimeConnectionMode.unconfigured, - useSetupCode: false, - setupCode: '', - host: resolvedHost, - port: resolvedPort <= 0 ? currentProfile.port : resolvedPort, - tls: _tls, + await widget.controller.setAssistantExecutionTarget( + AssistantExecutionTarget.aiGatewayOnly, ); - await widget.controller.saveSettings( - currentSettings.copyWith( - gateway: nextProfile, - assistantExecutionTarget: AssistantExecutionTarget.aiGatewayOnly, - ), - refreshAfterSave: false, - ); - if (widget.controller.connection.status == - RuntimeConnectionStatus.connected) { - await widget.controller.disconnectGateway(); - } } else { await widget.controller.connectManual( host: _hostController.text, diff --git a/pubspec.lock b/pubspec.lock index f3b68c5e..93b8c580 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -208,6 +216,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "https://pub.dev" + source: hosted + version: "0.7.7+1" flutter_secure_storage: dependency: "direct main" description: @@ -364,6 +380,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: "direct main" + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 14036119..90d954e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,9 @@ dependencies: device_info_plus: ^11.5.0 ffi: ^2.1.4 file_selector: ^1.0.3 + flutter_markdown: ^0.7.7+1 flutter_secure_storage: ^9.2.4 + markdown: ^7.3.0 package_info_plus: ^8.3.1 path_provider: ^2.1.5 shared_preferences: ^2.5.3 diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index 57bb3325..c6191135 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -1,8 +1,18 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/features/assistant/assistant_page.dart'; +import 'package:xworkmate/runtime/codex_runtime.dart'; +import 'package:xworkmate/runtime/device_identity_store.dart'; +import 'package:xworkmate/runtime/gateway_runtime.dart'; +import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; import '../test_support.dart'; @@ -154,7 +164,6 @@ void main() { expect(find.text('研发任务'), findsWidgets); }); - // Known flutter_tester host-exit hang in this widget scenario. testWidgets('AssistantPage groups task rows by execution target', ( WidgetTester tester, ) async { @@ -165,31 +174,24 @@ void main() { child: AssistantPage(controller: controller, onOpenDetail: (_) {}), ); - await controller.saveSettings( - controller.settings.copyWith( - assistantExecutionTarget: AssistantExecutionTarget.aiGatewayOnly, - ), - refreshAfterSave: false, + await tester.tap(find.byKey(const Key('assistant-new-task-button'))); + await _pumpForUiSync(tester); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.aiGatewayOnly, ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); + await _pumpForUiSync(tester); await tester.tap(find.byKey(const Key('assistant-new-task-button'))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); + await _pumpForUiSync(tester); - await controller.saveSettings( - controller.settings.copyWith( - assistantExecutionTarget: AssistantExecutionTarget.remote, - ), - refreshAfterSave: false, + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.remote, ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); + await _pumpForUiSync(tester); await tester.tap(find.byKey(const Key('assistant-new-task-button'))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); + await _pumpForUiSync(tester); final aiGroup = find.byKey( const ValueKey('assistant-task-group-aiGatewayOnly'), @@ -403,6 +405,143 @@ void main() { expect(find.text('网页处理'), findsOneWidget); }); + // Known flutter_tester host-exit hang in this widget scenario. + testWidgets( + 'AssistantPage syncs task selection with execution target menu and connection chip', + (WidgetTester tester) async { + final controller = await _createControllerWithThreadRecords( + records: const [], + useFakeGatewayRuntime: true, + ); + addTearDown(controller.dispose); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + await tester.tap(find.byKey(const Key('assistant-new-task-button'))); + await _pumpForUiSync(tester); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.aiGatewayOnly, + ); + await _pumpForUiSync(tester); + + await tester.tap( + find.byKey(const ValueKey('assistant-task-item-main')), + ); + await _pumpForUiSync(tester); + + expect( + find.descendant( + of: find.byKey(const Key('assistant-execution-target-button')), + matching: find.text('本地 OpenClaw Gateway'), + ), + findsOneWidget, + ); + expect(find.textContaining('离线 · 未连接目标'), findsOneWidget); + + final aiThreadItem = find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key as ValueKey).value.startsWith( + 'assistant-task-item-draft:', + ), + ); + expect(aiThreadItem, findsOneWidget); + + await tester.tap(aiThreadItem); + await _pumpForUiSync(tester); + + expect( + find.descendant( + of: find.byKey(const Key('assistant-execution-target-button')), + matching: find.text('仅 AI Gateway'), + ), + findsOneWidget, + ); + expect(find.textContaining('仅 AI Gateway'), findsWidgets); + }, + skip: true, + ); + + testWidgets('AssistantPage shows thread-level message view chip', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + expect( + find.byKey(const Key('assistant-message-view-mode-button')), + findsOneWidget, + ); + expect(find.text('渲染'), findsOneWidget); + }); + + // Known flutter_tester host-exit hang in this widget scenario. + testWidgets('AssistantPage toggles Markdown Rendered and RAW per thread', ( + WidgetTester tester, + ) async { + final controller = await _createControllerWithThreadRecords( + records: const [ + AssistantThreadRecord( + sessionKey: 'main', + title: '研发任务', + archived: false, + executionTarget: AssistantExecutionTarget.aiGatewayOnly, + messageViewMode: AssistantMessageViewMode.rendered, + updatedAtMs: 1700000000000, + messages: [ + GatewayChatMessage( + id: 'user-1', + role: 'user', + text: '请看这个清单', + timestampMs: 1700000000000, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + GatewayChatMessage( + id: 'assistant-1', + role: 'assistant', + text: '## 标题\\n\\n- 第一项\\n- 第二项', + timestampMs: 1700000001000, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ], + ), + ], + useFakeGatewayRuntime: true, + ); + addTearDown(controller.dispose); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + expect(find.byType(MarkdownBody), findsOneWidget); + + await tester.tap(find.byKey(const Key('assistant-message-view-mode-button'))); + await _pumpForUiSync(tester); + await tester.tap(find.text('RAW').last); + await _pumpForUiSync(tester); + + expect(controller.currentAssistantMessageViewMode, AssistantMessageViewMode.raw); + expect(find.byType(MarkdownBody), findsNothing); + }, skip: true); + // Known flutter_tester host-exit hang in this widget scenario. testWidgets( 'AssistantPage shows AI Gateway-only chip and keeps task rows minimal', @@ -445,3 +584,146 @@ void main() { skip: true, ); } + +Future _createControllerWithThreadRecords({ + required List records, + bool useFakeGatewayRuntime = false, +}) async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-assistant-page-tests-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + aiGateway: SettingsSnapshot.defaults().aiGateway.copyWith( + baseUrl: 'http://127.0.0.1:11434/v1', + availableModels: const ['qwen2.5-coder:latest'], + selectedModels: const ['qwen2.5-coder:latest'], + ), + assistantExecutionTarget: AssistantExecutionTarget.aiGatewayOnly, + defaultModel: 'qwen2.5-coder:latest', + ), + ); + await store.saveAssistantThreadRecords(records); + final controller = AppController( + store: store, + runtimeCoordinator: useFakeGatewayRuntime + ? RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ) + : null, + ); + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (controller.initializing) { + if (DateTime.now().isAfter(deadline)) { + fail('controller did not finish initializing before timeout'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } + return controller; +} + +Future _pumpForUiSync(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +} + +class _FakeGatewayRuntime extends GatewayRuntime { + _FakeGatewayRuntime({required super.store}) + : super(identityStore: DeviceIdentityStore(store)); + + GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); + + @override + bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; + + @override + GatewayConnectionSnapshot get snapshot => _snapshot; + + @override + Stream get events => const Stream.empty(); + + @override + Future connectProfile( + GatewayConnectionProfile profile, { + String authTokenOverride = '', + String authPasswordOverride = '', + }) async { + _snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith( + status: RuntimeConnectionStatus.connected, + statusText: 'Connected', + remoteAddress: '${profile.host}:${profile.port}', + connectAuthMode: 'none', + ); + notifyListeners(); + } + + @override + Future disconnect({bool clearDesiredProfile = true}) async { + _snapshot = _snapshot.copyWith( + status: RuntimeConnectionStatus.offline, + statusText: 'Offline', + remoteAddress: null, + clearLastError: true, + clearLastErrorCode: true, + clearLastErrorDetailCode: true, + ); + notifyListeners(); + } + + @override + Future request( + String method, { + Map? params, + Duration timeout = const Duration(seconds: 30), + }) async { + switch (method) { + case 'health': + case 'status': + return {'ok': true}; + case 'agents.list': + return {'agents': const [], 'mainKey': 'main'}; + case 'sessions.list': + return {'sessions': const []}; + case 'chat.history': + return {'messages': const []}; + case 'skills.status': + return {'skills': const []}; + case 'channels.status': + return { + 'channelMeta': const [], + 'channelLabels': const {}, + 'channelDetailLabels': const {}, + 'channelAccounts': const {}, + 'channelOrder': const [], + }; + case 'models.list': + return {'models': const []}; + case 'cron.list': + return {'jobs': const []}; + case 'device.pair.list': + return { + 'pending': const [], + 'paired': const [], + }; + case 'system-presence': + return const []; + default: + return {}; + } + } +} + +class _FakeCodexRuntime extends CodexRuntime { + @override + Future findCodexBinary() async => null; + + @override + Future stop() async {} +} diff --git a/test/runtime/app_controller_execution_target_switch_test.dart b/test/runtime/app_controller_execution_target_switch_test.dart index cc08e546..b6952407 100644 --- a/test/runtime/app_controller_execution_target_switch_test.dart +++ b/test/runtime/app_controller_execution_target_switch_test.dart @@ -213,12 +213,6 @@ void main() { controller.settings.assistantExecutionTarget, AssistantExecutionTarget.aiGatewayOnly, ); - expect( - controller.settings.gateway.mode, - RuntimeConnectionMode.unconfigured, - ); - expect(controller.settings.gateway.useSetupCode, isFalse); - expect(controller.settings.gateway.setupCode, isEmpty); expect( controller.settings.gateway.host, 'gateway.example.com', @@ -227,6 +221,7 @@ void main() { ); expect(controller.settings.gateway.port, 9443); expect(controller.settings.gateway.tls, isTrue); + expect(controller.settings.gateway.mode, RuntimeConnectionMode.remote); expect(gateway.disconnectCount, 1); expect(controller.assistantConnectionStatusLabel, '仅 AI Gateway'); expect( @@ -258,6 +253,144 @@ void main() { ); }, ); + + test( + 'AppController switches runtime state when the selected thread changes', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-mode-switch-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + 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'], + ), + gateway: controller.settings.gateway.copyWith( + mode: RuntimeConnectionMode.remote, + host: 'gateway.example.com', + port: 9443, + tls: true, + ), + ), + refreshAfterSave: false, + ); + + controller.initializeAssistantThreadContext( + 'main', + executionTarget: AssistantExecutionTarget.aiGatewayOnly, + ); + controller.initializeAssistantThreadContext( + 'remote-thread', + executionTarget: AssistantExecutionTarget.remote, + ); + + await controller.switchSession('remote-thread'); + + expect( + controller.assistantExecutionTarget, + AssistantExecutionTarget.remote, + ); + expect(gateway.connectedProfiles.last.mode, RuntimeConnectionMode.remote); + expect( + controller.settings.assistantExecutionTarget, + AssistantExecutionTarget.local, + reason: 'Thread switching should not overwrite the new-thread default.', + ); + + await controller.switchSession('main'); + + expect( + controller.assistantExecutionTarget, + AssistantExecutionTarget.aiGatewayOnly, + ); + expect(gateway.disconnectCount, 1); + expect(controller.assistantConnectionStatusLabel, '仅 AI Gateway'); + expect( + controller.settings.assistantExecutionTarget, + AssistantExecutionTarget.local, + ); + }, + ); + + test( + 'AppController persists markdown view mode per thread', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-view-mode-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final controller = AppController( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ), + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + + controller.initializeAssistantThreadContext( + 'main', + messageViewMode: AssistantMessageViewMode.raw, + ); + controller.initializeAssistantThreadContext( + 'draft:secondary', + messageViewMode: AssistantMessageViewMode.rendered, + ); + + await controller.switchSession('main'); + expect(controller.currentAssistantMessageViewMode, AssistantMessageViewMode.raw); + + await controller.switchSession('draft:secondary'); + expect( + controller.currentAssistantMessageViewMode, + AssistantMessageViewMode.rendered, + ); + + await controller.setAssistantMessageViewMode(AssistantMessageViewMode.raw); + expect(controller.currentAssistantMessageViewMode, AssistantMessageViewMode.raw); + + final reloaded = await store.loadAssistantThreadRecords(); + final secondary = reloaded.firstWhere((item) => item.sessionKey == 'draft:secondary'); + expect(secondary.messageViewMode, AssistantMessageViewMode.raw); + }, + ); } Future _waitFor(bool Function() predicate) async { diff --git a/test/runtime/secure_config_store_test.dart b/test/runtime/secure_config_store_test.dart index 13577f11..5420881f 100644 --- a/test/runtime/secure_config_store_test.dart +++ b/test/runtime/secure_config_store_test.dart @@ -234,6 +234,8 @@ void main() { sessionKey: 'main', title: '研发任务', archived: true, + executionTarget: AssistantExecutionTarget.remote, + messageViewMode: AssistantMessageViewMode.raw, updatedAtMs: 1700000000000, messages: [ GatewayChatMessage( @@ -276,6 +278,14 @@ void main() { expect(reloadedRecords.first.sessionKey, 'main'); expect(reloadedRecords.first.archived, isTrue); expect(reloadedRecords.first.title, '研发任务'); + expect( + reloadedRecords.first.executionTarget, + AssistantExecutionTarget.remote, + ); + expect( + reloadedRecords.first.messageViewMode, + AssistantMessageViewMode.raw, + ); expect(reloadedRecords.first.messages, hasLength(2)); expect(reloadedRecords.first.messages.last.text, '第一条回复'); },