Synchronize assistant threads and markdown view

This commit is contained in:
Haitao Pan 2026-03-20 12:37:19 +08:00
parent f8b32bffb3
commit 50b8a4dc98
9 changed files with 948 additions and 128 deletions

View File

@ -177,6 +177,7 @@ class AppController extends ChangeNotifier {
List<GatewaySessionSummary> get sessions => isAiGatewayOnlyMode
? _assistantSessionSummaries()
: _sessionsController.sessions;
List<GatewaySessionSummary> get assistantSessions => _assistantSessions();
List<GatewayInstanceSummary> get instances => _instancesController.items;
List<GatewaySkillSummary> get skills => _skillsController.items;
List<GatewayConnectorSummary> 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<GatewaySessionSummary> _assistantSessions() {
final archivedKeys = settings.assistantArchivedTaskKeys
.map(_normalizedAssistantSessionKey)
.toSet();
final byKey = <String, GatewaySessionSummary>{};
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<void> 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<void> disconnectGateway() async {
@ -916,8 +996,29 @@ class AppController extends ChangeNotifier {
}
Future<void> 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<void> 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<void> 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<void> setAssistantPermissionLevel(
@ -1028,6 +1126,56 @@ class AppController extends ChangeNotifier {
);
}
Future<void> _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<void> 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<void> 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,

View File

@ -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<AssistantPage> {
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<AssistantPage> {
Future<void> _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<AssistantPage> {
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<AssistantPage> {
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<AssistantPage> {
Future<void> _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<AssistantPage> {
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: _conversationOwnerLabel(widget.controller),
surface: 'Assistant',
executionTarget: widget.controller.assistantExecutionTarget,
executionTarget: inheritedTarget,
draft: true,
);
_selectedSkillKeys = const <String>[];
});
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<AssistantPage> {
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<AssistantPage> {
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<AssistantPage> {
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<AssistantPage> {
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<DetailPanelData> onOpenDetail;
final VoidCallback onFocusComposer;
final VoidCallback onOpenGateway;
final VoidCallback onOpenAiGatewaySettings;
final Future<void> Function() onReconnectGateway;
final Future<void> 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<void> Function(AssistantMessageViewMode mode) onSelected;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return PopupMenuButton<AssistantMessageViewMode>(
key: const Key('assistant-message-view-mode-button'),
tooltip: appText('消息视图', 'Message view'),
onSelected: (mode) => unawaited(onSelected(mode)),
itemBuilder: (context) => AssistantMessageViewMode.values
.map(
(mode) => PopupMenuItem<AssistantMessageViewMode>(
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 }

View File

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

View File

@ -68,12 +68,20 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
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<GatewayConnectDialog> {
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,

View File

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

View File

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

View File

@ -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<String>('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 <AssistantThreadRecord>[],
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<String>('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<String> &&
(widget.key as ValueKey<String>).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>[
AssistantThreadRecord(
sessionKey: 'main',
title: '研发任务',
archived: false,
executionTarget: AssistantExecutionTarget.aiGatewayOnly,
messageViewMode: AssistantMessageViewMode.rendered,
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[
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<AppController> _createControllerWithThreadRecords({
required List<AssistantThreadRecord> records,
bool useFakeGatewayRuntime = false,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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 <String>['qwen2.5-coder:latest'],
selectedModels: const <String>['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<void>.delayed(const Duration(milliseconds: 20));
}
return controller;
}
Future<void> _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<GatewayPushEvent> get events => const Stream<GatewayPushEvent>.empty();
@override
Future<void> 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<void> disconnect({bool clearDesiredProfile = true}) async {
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.offline,
statusText: 'Offline',
remoteAddress: null,
clearLastError: true,
clearLastErrorCode: true,
clearLastErrorDetailCode: true,
);
notifyListeners();
}
@override
Future<dynamic> request(
String method, {
Map<String, dynamic>? params,
Duration timeout = const Duration(seconds: 30),
}) async {
switch (method) {
case 'health':
case 'status':
return <String, dynamic>{'ok': true};
case 'agents.list':
return <String, dynamic>{'agents': const <Object>[], 'mainKey': 'main'};
case 'sessions.list':
return <String, dynamic>{'sessions': const <Object>[]};
case 'chat.history':
return <String, dynamic>{'messages': const <Object>[]};
case 'skills.status':
return <String, dynamic>{'skills': const <Object>[]};
case 'channels.status':
return <String, dynamic>{
'channelMeta': const <Object>[],
'channelLabels': const <String, dynamic>{},
'channelDetailLabels': const <String, dynamic>{},
'channelAccounts': const <String, dynamic>{},
'channelOrder': const <Object>[],
};
case 'models.list':
return <String, dynamic>{'models': const <Object>[]};
case 'cron.list':
return <String, dynamic>{'jobs': const <Object>[]};
case 'device.pair.list':
return <String, dynamic>{
'pending': const <Object>[],
'paired': const <Object>[],
};
case 'system-presence':
return const <Object>[];
default:
return <String, dynamic>{};
}
}
}
class _FakeCodexRuntime extends CodexRuntime {
@override
Future<String?> findCodexBinary() async => null;
@override
Future<void> stop() async {}
}

View File

@ -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(<String, Object>{});
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 <String>['qwen2.5-coder:latest'],
selectedModels: const <String>['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(<String, Object>{});
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<void> _waitFor(bool Function() predicate) async {

View File

@ -234,6 +234,8 @@ void main() {
sessionKey: 'main',
title: '研发任务',
archived: true,
executionTarget: AssistantExecutionTarget.remote,
messageViewMode: AssistantMessageViewMode.raw,
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[
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, '第一条回复');
},