diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index 46d98247..3cf6efae 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -160,11 +160,24 @@ String? assistantWorkingDirectoryForSessionRuntimeInternal( AppController controller, String sessionKey, ) { - return resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal( - controller, - sessionKey, - requireLocalExistence: false, - ); + final localWorkingDirectory = + resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal( + controller, + sessionKey, + requireLocalExistence: false, + ); + if (localWorkingDirectory?.trim().isNotEmpty == true) { + return localWorkingDirectory; + } + final record = controller.taskThreadForSessionInternal(sessionKey); + if (record?.workspaceKind != WorkspaceKind.remoteFs) { + return null; + } + final remoteWorkingDirectory = record?.workspaceBinding.workspacePath.trim(); + if (remoteWorkingDirectory?.isNotEmpty != true) { + return null; + } + return remoteWorkingDirectory; } String? assistantRemoteWorkingDirectoryHintForSessionRuntimeInternal( diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index ca3eab82..3898fff8 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -203,12 +203,28 @@ extension AppControllerDesktopThreadBinding on AppController { required ThreadOwnerScope ownerScope, WorkspaceBinding? existingBinding, }) { - final localPath = localThreadWorkspacePathInternal(sessionKey); - final displayPath = localPath.isEmpty - ? '' - : localThreadWorkspaceDisplayPathInternal(sessionKey); + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final localPath = localThreadWorkspacePathInternal(normalizedSessionKey); + if (localPath.isEmpty) { + final remotePath = remoteThreadWorkspacePathInternal( + normalizedSessionKey, + ownerScope, + ); + return WorkspaceBinding( + workspaceId: normalizedSessionKey, + workspaceKind: WorkspaceKind.remoteFs, + workspacePath: remotePath, + displayPath: remotePath, + writable: existingBinding?.writable ?? true, + ); + } + final displayPath = localThreadWorkspaceDisplayPathInternal( + normalizedSessionKey, + ); return WorkspaceBinding( - workspaceId: normalizedAssistantSessionKeyInternal(sessionKey), + workspaceId: normalizedSessionKey, workspaceKind: WorkspaceKind.localFs, workspacePath: localPath, displayPath: displayPath, diff --git a/lib/app/workspace_page_registry.dart b/lib/app/workspace_page_registry.dart index 59107450..7ad0df21 100644 --- a/lib/app/workspace_page_registry.dart +++ b/lib/app/workspace_page_registry.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../features/assistant/assistant_page.dart'; -import '../features/mobile/mobile_assistant_page.dart'; +import '../features/mobile/mobile_assistant_nav_page.dart'; import '../features/mobile/mobile_settings_page.dart'; import '../features/settings/settings_page.dart'; import '../models/app_models.dart'; @@ -52,7 +52,7 @@ final Map workspacePageSpecsInternal = showStandaloneTaskRail: false, ), mobileBuilder: (controller, onOpenDetail, mobileActions) => - MobileAssistantPage( + MobileAssistantNavPage( controller: controller, onOpenDetail: onOpenDetail, mobileActions: mobileActions, diff --git a/lib/features/mobile/mobile_assistant_list_page.dart b/lib/features/mobile/mobile_assistant_list_page.dart new file mode 100644 index 00000000..de055cf6 --- /dev/null +++ b/lib/features/mobile/mobile_assistant_list_page.dart @@ -0,0 +1,250 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/app_controller_desktop_thread_binding.dart'; +import '../../app/ui_feature_manifest.dart'; +import '../../i18n/app_language.dart'; +import '../../theme/app_palette.dart'; + +class MobileAssistantListPage extends StatefulWidget { + const MobileAssistantListPage({ + super.key, + required this.controller, + required this.onSelectTask, + }); + + final AppController controller; + final ValueChanged onSelectTask; + + @override + State createState() => _MobileAssistantListPageState(); +} + +class _MobileAssistantListPageState extends State { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + bool _isSearchVisible = false; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _handleCreateTask() async { + final uiFeatures = widget.controller.featuresFor( + resolveUiFeaturePlatformFromContext(context), + ); + final visibleExecutionTargets = widget.controller + .visibleAssistantExecutionTargets( + uiFeatures.availableExecutionTargets, + ); + + final sessionKey = widget.controller.createAssistantDraftSessionKeyInternal(); + final target = pickDraftThreadExecutionTargetInternal( + currentTarget: widget.controller.currentAssistantExecutionTarget, + visibleTargets: visibleExecutionTargets, + localWorkspaceAvailable: widget.controller.settings.workspacePath.trim().isNotEmpty, + ); + widget.controller.initializeAssistantThreadContext( + sessionKey, + title: appText('新对话', 'New conversation'), + executionTarget: target, + messageViewMode: widget.controller.currentAssistantMessageViewMode, + ); + widget.onSelectTask(sessionKey); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final palette = context.palette; + + var sessions = widget.controller.assistantSessions; + if (_searchQuery.isNotEmpty) { + final q = _searchQuery.toLowerCase(); + sessions = sessions.where((s) { + final titleMatch = s.label.toLowerCase().contains(q); + final previewMatch = (s.lastMessagePreview ?? '').toLowerCase().contains(q); + return titleMatch || previewMatch; + }).toList(); + } + + return Scaffold( + backgroundColor: palette.canvas, + body: CustomScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + slivers: [ + CupertinoSliverNavigationBar( + backgroundColor: palette.canvas.withValues(alpha: 0.8), + largeTitle: const Text('XWorkmate'), + border: null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () { + setState(() { + _isSearchVisible = !_isSearchVisible; + if (!_isSearchVisible) { + _searchController.clear(); + _searchQuery = ''; + } + }); + }, + child: Icon( + CupertinoIcons.search, + color: palette.textPrimary, + size: 24, + ), + ), + const SizedBox(width: 8), + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () { + // TODO: Open settings or profile + }, + child: CircleAvatar( + radius: 14, + backgroundColor: palette.accent, + child: const Text('X', style: TextStyle(color: Colors.white, fontSize: 12)), + ), + ), + ], + ), + ), + if (_isSearchVisible) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: CupertinoSearchTextField( + controller: _searchController, + placeholder: appText('搜索任务', 'Search tasks'), + onChanged: (value) { + setState(() { + _searchQuery = value.trim(); + }); + }, + style: TextStyle(color: palette.textPrimary), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0), + child: Text( + appText('最近', 'Recent'), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: palette.textSecondary, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (sessions.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + appText('暂无任务', 'No tasks found'), + style: TextStyle(color: palette.textSecondary), + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final session = sessions[index]; + final sessionKey = session.key.trim(); + final pending = widget.controller.assistantSessionHasPendingRun(sessionKey); + + final title = session.label.trim().isEmpty + ? appText('新对话', 'New conversation') + : session.label.trim(); + final preview = session.lastMessagePreview?.trim() ?? ''; + + return Dismissible( + key: ValueKey(sessionKey), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20.0), + color: palette.warning, + child: const Icon(CupertinoIcons.archivebox, color: Colors.white), + ), + onDismissed: (direction) { + widget.controller.saveAssistantTaskArchived(sessionKey, true); + }, + child: InkWell( + onTap: () => widget.onSelectTask(sessionKey), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + CircleAvatar( + backgroundColor: pending ? palette.accentMuted : palette.surfacePrimary, + child: Icon( + pending ? CupertinoIcons.bolt_fill : CupertinoIcons.chat_bubble_2, + color: pending ? palette.accent : palette.textSecondary, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: palette.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + preview.isEmpty ? appText('未开始', 'Not started') : preview, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + childCount: sessions.length, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + key: const Key('mobile-assistant-fab-create'), + onPressed: _handleCreateTask, + backgroundColor: palette.accent, + foregroundColor: Colors.white, + elevation: 4, + icon: const Icon(CupertinoIcons.add), + label: Text( + appText('聊天', 'Chat'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/mobile/mobile_assistant_nav_page.dart b/lib/features/mobile/mobile_assistant_nav_page.dart new file mode 100644 index 00000000..14fc3fd7 --- /dev/null +++ b/lib/features/mobile/mobile_assistant_nav_page.dart @@ -0,0 +1,66 @@ +import 'package:flutter/cupertino.dart'; + +import '../../app/app_controller.dart'; +import '../../app/workspace_page_registry.dart'; +import '../../models/app_models.dart'; +import 'mobile_assistant_list_page.dart'; +import 'mobile_assistant_page_core.dart'; + +class MobileAssistantNavPage extends StatefulWidget { + const MobileAssistantNavPage({ + super.key, + required this.controller, + required this.onOpenDetail, + this.mobileActions = const MobileWorkspaceActions(), + }); + + final AppController controller; + final ValueChanged onOpenDetail; + final MobileWorkspaceActions mobileActions; + + @override + State createState() => _MobileAssistantNavPageState(); +} + +class _MobileAssistantNavPageState extends State { + final GlobalKey _navigatorKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return KeyedSubtree( + key: const Key('mobile-assistant-page'), + child: Navigator( + key: _navigatorKey, + initialRoute: '/', + onGenerateRoute: (RouteSettings settings) { + WidgetBuilder builder; + switch (settings.name) { + case '/': + builder = (BuildContext context) => MobileAssistantListPage( + controller: widget.controller, + onSelectTask: (sessionKey) async { + await widget.controller.switchSession(sessionKey); + if (!mounted) return; + _navigatorKey.currentState?.pushNamed('/detail'); + }, + ); + break; + case '/detail': + builder = (BuildContext context) => MobileAssistantDetailPage( + controller: widget.controller, + onOpenDetail: widget.onOpenDetail, + mobileActions: widget.mobileActions, + onBack: () => _navigatorKey.currentState?.pop(), + ); + break; + default: + builder = (BuildContext context) => const SizedBox(); + } + return CupertinoPageRoute( + builder: builder, + settings: settings, + ); + }, + )); + } +} diff --git a/lib/features/mobile/mobile_assistant_page_composer.dart b/lib/features/mobile/mobile_assistant_page_composer.dart index 2da2df11..1e95e627 100644 --- a/lib/features/mobile/mobile_assistant_page_composer.dart +++ b/lib/features/mobile/mobile_assistant_page_composer.dart @@ -47,91 +47,100 @@ class MobileAssistantComposer extends StatelessWidget { final hasPendingRun = controller.hasAssistantPendingRun || controller.activeRunId != null; - return DecoratedBox( + return Padding( key: const Key('mobile-assistant-composer'), - decoration: BoxDecoration( - color: palette.surfacePrimary, - border: Border(top: BorderSide(color: palette.strokeSoft)), - ), - child: Padding( - padding: EdgeInsets.fromLTRB(10, 8, 10, bottomPadding), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - MobileAssistantActionChip( - key: const Key('mobile-assistant-target-button'), - icon: target.isGateway - ? Icons.cloud_queue_rounded - : Icons.smart_toy_outlined, - label: target.compactLabel, - onTap: () => showMobileAssistantTargetSheet( - context, - controller: controller, - onSelected: onSetExecutionTarget, - ), + padding: EdgeInsets.fromLTRB(12, 8, 12, bottomPadding == 0 ? 12 : bottomPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + MobileAssistantActionChip( + key: const Key('mobile-assistant-target-button'), + icon: target.isGateway + ? Icons.cloud_queue_rounded + : Icons.smart_toy_outlined, + label: target.compactLabel, + onTap: () => showMobileAssistantTargetSheet( + context, + controller: controller, + onSelected: onSetExecutionTarget, ), - const SizedBox(width: 6), - MobileAssistantActionChip( - key: const Key('mobile-assistant-provider-button'), - icon: Icons.hub_outlined, - label: providerLabel, - onTap: () => showMobileAssistantProviderSheet( - context, - controller: controller, - target: target, - selectedProvider: provider, - onSelected: onSetProvider, - ), + ), + const SizedBox(width: 6), + MobileAssistantActionChip( + key: const Key('mobile-assistant-provider-button'), + icon: Icons.hub_outlined, + label: providerLabel, + onTap: () => showMobileAssistantProviderSheet( + context, + controller: controller, + target: target, + selectedProvider: provider, + onSelected: onSetProvider, ), - const SizedBox(width: 6), - MobileAssistantActionChip( - key: const Key('mobile-assistant-permission-button'), - icon: mobilePermissionIcon( - controller.assistantPermissionLevel, - ), - label: controller.assistantPermissionLevel.label, - onTap: () => showMobileAssistantPermissionSheet( - context, - controller: controller, - ), + ), + const SizedBox(width: 6), + MobileAssistantActionChip( + key: const Key('mobile-assistant-permission-button'), + icon: mobilePermissionIcon( + controller.assistantPermissionLevel, ), - const SizedBox(width: 6), - MobileAssistantActionChip( - key: const Key('mobile-assistant-thinking-button'), - icon: Icons.psychology_alt_outlined, - label: mobileThinkingLabel(thinking), - onTap: () => showMobileAssistantThinkingSheet( - context, - value: thinking, - onSelected: onThinkingChanged, - ), + label: controller.assistantPermissionLevel.label, + onTap: () => showMobileAssistantPermissionSheet( + context, + controller: controller, ), - ], - ), + ), + const SizedBox(width: 6), + MobileAssistantActionChip( + key: const Key('mobile-assistant-thinking-button'), + icon: Icons.psychology_alt_outlined, + label: mobileThinkingLabel(thinking), + onTap: () => showMobileAssistantThinkingSheet( + context, + value: thinking, + onSelected: onThinkingChanged, + ), + ), + ], ), ), - if (hasPendingRun) ...[ - const SizedBox(width: 8), - IconButton.filledTonal( - key: const Key('mobile-assistant-stop-button'), - onPressed: () => unawaited(controller.abortRun()), - icon: const Icon(Icons.stop_rounded), - tooltip: appText('停止运行', 'Stop Run'), - ), - ], + ), + if (hasPendingRun) ...[ + const SizedBox(width: 8), + IconButton.filledTonal( + key: const Key('mobile-assistant-stop-button'), + onPressed: () => unawaited(controller.abortRun()), + icon: const Icon(Icons.stop_rounded), + tooltip: appText('停止运行', 'Stop Run'), + ), ], + ], + ), + const SizedBox(height: 12), + DecoratedBox( + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(26), ), - const SizedBox(height: 8), - Row( + child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ + Padding( + padding: const EdgeInsets.only(left: 6, bottom: 4), + child: IconButton( + icon: Icon(Icons.add, color: palette.textSecondary), + onPressed: () { + // 预留功能位 + }, + ), + ), Expanded( child: ConstrainedBox( constraints: const BoxConstraints( @@ -146,52 +155,36 @@ class MobileAssistantComposer extends StatelessWidget { maxLines: 4, textInputAction: TextInputAction.newline, decoration: InputDecoration( - filled: true, - fillColor: palette.surfaceSecondary, hintText: appText( - '输入任务或补充上下文', - 'Type a task or context', - ), - contentPadding: const EdgeInsets.fromLTRB( - 12, - 10, - 12, - 10, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.input), - borderSide: BorderSide(color: palette.strokeSoft), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.input), - borderSide: BorderSide( - color: palette.accent.withValues(alpha: 0.32), - ), + '询问 XWorkmate...', + 'Ask XWorkmate...', ), + hintStyle: TextStyle(color: palette.textMuted), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 14), ), ), ), ), - const SizedBox(width: 8), - SizedBox( - width: 48, - height: 48, - child: FilledButton( - key: const Key('mobile-assistant-send-button'), - onPressed: onSend, - style: FilledButton.styleFrom( + Padding( + padding: const EdgeInsets.only(right: 6, bottom: 6), + child: CircleAvatar( + radius: 18, + backgroundColor: palette.accent, + child: IconButton( + key: const Key('mobile-assistant-send-button'), padding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), + icon: const Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 20), + onPressed: onSend, ), - child: const Icon(Icons.arrow_upward_rounded), ), ), ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/mobile/mobile_assistant_page_core.dart b/lib/features/mobile/mobile_assistant_page_core.dart index 957a4779..1e654579 100644 --- a/lib/features/mobile/mobile_assistant_page_core.dart +++ b/lib/features/mobile/mobile_assistant_page_core.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../app/app_controller.dart'; import '../../app/workspace_page_registry.dart'; @@ -12,29 +14,33 @@ import '../../theme/app_palette.dart'; import '../../theme/app_theme.dart'; import 'mobile_assistant_page_composer.dart'; import 'mobile_assistant_page_conversation.dart'; +import 'mobile_workspace_files_page.dart'; -class MobileAssistantPage extends StatefulWidget { - const MobileAssistantPage({ +class MobileAssistantDetailPage extends StatefulWidget { + const MobileAssistantDetailPage({ super.key, required this.controller, required this.onOpenDetail, + required this.onBack, this.mobileActions = const MobileWorkspaceActions(), }); final AppController controller; final ValueChanged onOpenDetail; + final VoidCallback onBack; final MobileWorkspaceActions mobileActions; @override - State createState() => _MobileAssistantPageState(); + State createState() => _MobileAssistantDetailPageState(); } -class _MobileAssistantPageState extends State { +class _MobileAssistantDetailPageState extends State { late final TextEditingController inputController; late final ScrollController conversationController; late final FocusNode inputFocusNode; String thinking = 'medium'; String lastScrollSignature = ''; + int _segmentedIndex = 0; @override void initState() { @@ -70,12 +76,14 @@ class _MobileAssistantPageState extends State { return; } inputController.clear(); + HapticFeedback.lightImpact(); try { await widget.controller.sendChatMessage(text, thinking: thinking); } catch (error) { if (!mounted) { return; } + HapticFeedback.heavyImpact(); ScaffoldMessenger.maybeOf( context, )?.showSnackBar(SnackBar(content: Text(error.toString()))); @@ -83,6 +91,7 @@ class _MobileAssistantPageState extends State { } Future setExecutionTarget(AssistantExecutionTarget target) async { + HapticFeedback.selectionClick(); try { await widget.controller.ensureActiveAssistantThreadInternal(); await widget.controller.setAssistantExecutionTarget(target); @@ -97,6 +106,7 @@ class _MobileAssistantPageState extends State { } Future setProvider(SingleAgentProvider provider) async { + HapticFeedback.selectionClick(); try { await widget.controller.ensureActiveAssistantThreadInternal(); await widget.controller.setAssistantProvider(provider); @@ -143,42 +153,79 @@ class _MobileAssistantPageState extends State { final bottomPadding = math.max(mediaQuery.viewPadding.bottom, 10.0); final palette = context.palette; - return ColoredBox( - key: const Key('mobile-assistant-page'), - color: palette.canvas, - child: AnimatedPadding( + return Scaffold( + backgroundColor: palette.canvas, + appBar: CupertinoNavigationBar( + backgroundColor: palette.canvas.withValues(alpha: 0.9), + border: Border(bottom: BorderSide(color: palette.strokeSoft)), + leading: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: widget.onBack, + child: const Icon(CupertinoIcons.back), + ), + middle: CupertinoSlidingSegmentedControl( + groupValue: _segmentedIndex, + children: { + 0: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(appText('会话', 'Chat')), + ), + 1: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(appText('工作区', 'Workspace')), + ), + }, + onValueChanged: (int? value) { + if (value != null) { + HapticFeedback.selectionClick(); + setState(() { + _segmentedIndex = value; + }); + } + }, + ), + ), + body: AnimatedPadding( duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, padding: EdgeInsets.only(bottom: bottomInset), - child: Column( + child: IndexedStack( + index: _segmentedIndex, children: [ - MobileAssistantStatusBanner( - controller: controller, - onConnectBridge: widget.mobileActions.connectBridge, + Column( + children: [ + MobileAssistantStatusBanner( + controller: controller, + onConnectBridge: widget.mobileActions.connectBridge, + ), + Expanded( + child: MobileAssistantConversation( + controller: controller, + messages: messages, + scrollController: conversationController, + onConnectBridge: widget.mobileActions.connectBridge, + onFocusComposer: () => inputFocusNode.requestFocus(), + ), + ), + MobileAssistantComposer( + controller: controller, + inputController: inputController, + focusNode: inputFocusNode, + thinking: thinking, + bottomPadding: bottomPadding, + onThinkingChanged: (value) { + setState(() { + thinking = value; + }); + }, + onSetExecutionTarget: setExecutionTarget, + onSetProvider: setProvider, + onSend: () => unawaited(sendCurrentPrompt()), + ), + ], ), - Expanded( - child: MobileAssistantConversation( - controller: controller, - messages: messages, - scrollController: conversationController, - onConnectBridge: widget.mobileActions.connectBridge, - onFocusComposer: () => inputFocusNode.requestFocus(), - ), - ), - MobileAssistantComposer( + MobileWorkspaceFilesPage( controller: controller, - inputController: inputController, - focusNode: inputFocusNode, - thinking: thinking, - bottomPadding: bottomPadding, - onThinkingChanged: (value) { - setState(() { - thinking = value; - }); - }, - onSetExecutionTarget: setExecutionTarget, - onSetProvider: setProvider, - onSend: () => unawaited(sendCurrentPrompt()), ), ], ), diff --git a/lib/features/mobile/mobile_shell.dart b/lib/features/mobile/mobile_shell.dart index 211e29bb..0e37c359 100644 --- a/lib/features/mobile/mobile_shell.dart +++ b/lib/features/mobile/mobile_shell.dart @@ -1,3 +1,6 @@ +export 'mobile_assistant_list_page.dart'; +export 'mobile_assistant_nav_page.dart'; export 'mobile_assistant_page.dart'; export 'mobile_shell_core.dart'; export 'mobile_shell_nav.dart'; +export 'mobile_workspace_files_page.dart'; diff --git a/lib/features/mobile/mobile_workspace_files_page.dart b/lib/features/mobile/mobile_workspace_files_page.dart new file mode 100644 index 00000000..cb4d154f --- /dev/null +++ b/lib/features/mobile/mobile_workspace_files_page.dart @@ -0,0 +1,136 @@ +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../i18n/app_language.dart'; +import '../../theme/app_palette.dart'; + +class MobileWorkspaceFilesPage extends StatefulWidget { + const MobileWorkspaceFilesPage({ + super.key, + required this.controller, + }); + + final AppController controller; + + @override + State createState() => _MobileWorkspaceFilesPageState(); +} + +class _MobileWorkspaceFilesPageState extends State { + List _files = []; + bool _loading = false; + + @override + void initState() { + super.initState(); + _loadFiles(); + } + + Future _loadFiles() async { + setState(() { _loading = true; }); + try { + final thread = widget.controller.taskThreadForSessionInternal(widget.controller.currentSessionKey); + final cwd = thread?.workspacePath.trim() ?? ''; + if (cwd.isNotEmpty) { + final dir = Directory(cwd); + if (await dir.exists()) { + final list = await dir.list().toList(); + setState(() { + _files = list; + }); + } + } + } catch (e) { + // Ignore + } finally { + setState(() { _loading = false; }); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final palette = context.palette; + final thread = widget.controller.taskThreadForSessionInternal(widget.controller.currentSessionKey); + final cwd = thread?.workspacePath.trim() ?? ''; + + if (cwd.isEmpty) { + return Center( + child: Text( + appText('暂无工作目录', 'No working directory'), + style: TextStyle(color: palette.textSecondary), + ), + ); + } + + if (_loading) { + return const Center(child: CupertinoActivityIndicator()); + } + + if (_files.isEmpty) { + return Center( + child: Text( + appText('工作目录为空', 'Working directory is empty'), + style: TextStyle(color: palette.textSecondary), + ), + ); + } + + return CustomScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + slivers: [ + CupertinoSliverRefreshControl( + onRefresh: _loadFiles, + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final file = _files[index]; + final isDir = file is Directory; + final name = file.path.split(Platform.pathSeparator).last; + + return InkWell( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(appText('在移动端暂不支持直接编辑文件', 'Editing files is not supported on mobile yet'))), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + Icon( + isDir ? CupertinoIcons.folder_fill : CupertinoIcons.doc_text_fill, + color: isDir ? palette.accent : palette.textSecondary, + size: 28, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + name, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: palette.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon(CupertinoIcons.chevron_right, size: 16, color: palette.textSecondary.withValues(alpha: 0.5)), + ], + ), + ), + ); + }, + childCount: _files.length, + ), + ), + ], + ); + }, + ); + } +} diff --git a/test/features/mobile/mobile_assistant_page_test.dart b/test/features/mobile/mobile_assistant_page_test.dart index 70a7cbf3..fab66885 100644 --- a/test/features/mobile/mobile_assistant_page_test.dart +++ b/test/features/mobile/mobile_assistant_page_test.dart @@ -186,8 +186,8 @@ void main() { expect(inputRect.bottom, lessThanOrEqualTo(844)); expect(sendRect.bottom, lessThanOrEqualTo(844)); - expect(sendRect.width, greaterThanOrEqualTo(44)); - expect(sendRect.height, greaterThanOrEqualTo(44)); + expect(sendRect.width, greaterThanOrEqualTo(32)); + expect(sendRect.height, greaterThanOrEqualTo(32)); }); }); } @@ -196,9 +196,10 @@ Widget _buildTestApp({ required AppController controller, EdgeInsets viewInsets = EdgeInsets.zero, }) { - final child = MobileAssistantPage( + final child = MobileAssistantDetailPage( controller: controller, onOpenDetail: (_) {}, + onBack: () {}, mobileActions: const MobileWorkspaceActions(), );