From d5bc0784f9dee8541bbe7c021b6f1289907ec81c Mon Sep 17 00:00:00 2001 From: sladro Date: Wed, 1 Apr 2026 16:56:19 +0800 Subject: [PATCH] feat: polish mobile terminal and session management --- apps/mobile_app/lib/app/app.dart | 4 +- apps/mobile_app/lib/app/app_theme.dart | 139 ++++ apps/mobile_app/lib/app/ui_shell.dart | 133 +++ .../projects/project_detail_page.dart | 75 +- .../features/projects/project_list_page.dart | 489 +++++++++-- .../features/sessions/session_list_page.dart | 256 +++--- .../lib/features/terminal/terminal_page.dart | 780 +++++++++--------- apps/mobile_app/test/project_home_test.dart | 7 + apps/mobile_app/test/widget_test.dart | 172 +++- 9 files changed, 1475 insertions(+), 580 deletions(-) create mode 100644 apps/mobile_app/lib/app/app_theme.dart create mode 100644 apps/mobile_app/lib/app/ui_shell.dart diff --git a/apps/mobile_app/lib/app/app.dart b/apps/mobile_app/lib/app/app.dart index 1aa3fa5..ba88029 100644 --- a/apps/mobile_app/lib/app/app.dart +++ b/apps/mobile_app/lib/app/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../features/projects/project_list_page.dart'; +import 'app_theme.dart'; class TermRemoteCtlApp extends StatelessWidget { const TermRemoteCtlApp({super.key}); @@ -9,7 +10,8 @@ class TermRemoteCtlApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'TermRemoteCtl', - theme: ThemeData(colorSchemeSeed: Colors.blue), + debugShowCheckedModeBanner: false, + theme: AppTheme.build(), home: const ProjectListPage(), ); } diff --git a/apps/mobile_app/lib/app/app_theme.dart b/apps/mobile_app/lib/app/app_theme.dart new file mode 100644 index 0000000..346603a --- /dev/null +++ b/apps/mobile_app/lib/app/app_theme.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static const EdgeInsets pagePadding = EdgeInsets.fromLTRB(20, 16, 20, 24); + static const EdgeInsets panelPadding = EdgeInsets.all(16); + static const BorderRadius panelRadius = BorderRadius.all(Radius.circular(24)); + + static ThemeData build() { + const background = Color(0xFF09111C); + const surface = Color(0xFF111C2C); + const surfaceHigh = Color(0xFF182538); + const surfaceHighest = Color(0xFF1E2E45); + const outline = Color(0xFF2C405F); + const outlineVariant = Color(0xFF22324A); + const accent = Color(0xFF67CFFF); + const secondary = Color(0xFF7AA6FF); + const tertiary = Color(0xFF77E3CF); + + final scheme = const ColorScheme.dark().copyWith( + primary: accent, + secondary: secondary, + tertiary: tertiary, + surface: surface, + surfaceContainer: surface, + surfaceContainerHigh: surfaceHigh, + surfaceContainerHighest: surfaceHighest, + outline: outline, + outlineVariant: outlineVariant, + error: const Color(0xFFFF7575), + ); + + final base = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: scheme, + scaffoldBackgroundColor: background, + canvasColor: background, + splashFactory: InkSparkle.splashFactory, + ); + + return base.copyWith( + appBarTheme: AppBarTheme( + centerTitle: false, + elevation: 0, + backgroundColor: background, + surfaceTintColor: Colors.transparent, + titleTextStyle: base.textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + textTheme: base.textTheme.copyWith( + headlineSmall: base.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + ), + titleMedium: base.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + titleSmall: base.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + labelLarge: base.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + bodySmall: base.textTheme.bodySmall?.copyWith( + color: const Color(0xFFA4B4CB), + ), + ), + cardTheme: const CardThemeData( + color: Colors.transparent, + elevation: 0, + margin: EdgeInsets.zero, + ), + dialogTheme: DialogThemeData( + backgroundColor: surfaceHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + inputDecorationTheme: InputDecorationTheme( + isDense: true, + filled: true, + fillColor: surface, + hintStyle: const TextStyle(color: Color(0xFF8195B2)), + labelStyle: const TextStyle(color: Color(0xFFAFC1DB)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: const BorderSide(color: outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: const BorderSide(color: outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: const BorderSide(color: accent), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(0, 48), + backgroundColor: accent, + foregroundColor: const Color(0xFF05111D), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 48), + side: const BorderSide(color: outline), + foregroundColor: const Color(0xFFE8F1FF), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + ), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: const Color(0xFFD7E4F8)), + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: accent, + foregroundColor: Color(0xFF05111D), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: surfaceHighest, + contentTextStyle: const TextStyle(color: Color(0xFFF2F7FF)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), + dividerTheme: const DividerThemeData(color: outlineVariant, thickness: 1), + ); + } +} diff --git a/apps/mobile_app/lib/app/ui_shell.dart b/apps/mobile_app/lib/app/ui_shell.dart new file mode 100644 index 0000000..775161f --- /dev/null +++ b/apps/mobile_app/lib/app/ui_shell.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import 'app_theme.dart'; + +class AppPanel extends StatelessWidget { + const AppPanel({ + super.key, + required this.child, + this.padding = AppTheme.panelPadding, + this.borderRadius = AppTheme.panelRadius, + }); + + final Widget child; + final EdgeInsetsGeometry padding; + final BorderRadiusGeometry borderRadius; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: borderRadius, + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Padding(padding: padding, child: child), + ); + } +} + +class AppSectionHeader extends StatelessWidget { + const AppSectionHeader({ + super.key, + required this.eyebrow, + required this.title, + required this.subtitle, + }); + + final String eyebrow; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(eyebrow, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + Text(title, style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 4), + Text(subtitle, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } +} + +class AppEmptyState extends StatelessWidget { + const AppEmptyState({ + super.key, + required this.icon, + required this.title, + required this.message, + }); + + final IconData icon; + final String title; + final String message; + + @override + Widget build(BuildContext context) { + return AppPanel( + child: Column( + children: [ + Icon(icon, size: 56, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } +} + +class StatusPill extends StatelessWidget { + const StatusPill({ + super.key, + required this.label, + required this.icon, + required this.color, + }); + + final String label; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.24)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile_app/lib/features/projects/project_detail_page.dart b/apps/mobile_app/lib/features/projects/project_detail_page.dart index c339635..68cc010 100644 --- a/apps/mobile_app/lib/features/projects/project_detail_page.dart +++ b/apps/mobile_app/lib/features/projects/project_detail_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../app/app_theme.dart'; +import '../../app/ui_shell.dart'; import '../../core/network/agent_connection_providers.dart'; import '../../core/network/agent_error_formatter.dart'; import '../sessions/session.dart'; @@ -207,6 +209,8 @@ class _ProjectDetailPageState extends ConsumerState { @override Widget build(BuildContext context) { + final isCompact = MediaQuery.sizeOf(context).width < 460; + return Scaffold( appBar: AppBar( title: Text(widget.project.name), @@ -242,35 +246,69 @@ class _ProjectDetailPageState extends ConsumerState { final detail = snapshot.data!; final project = detail.project; return ListView( - padding: const EdgeInsets.all(16), + padding: AppTheme.pagePadding, children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - project.name, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text(project.workingDirectory), - const SizedBox(height: 16), - Row( + AppPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + project.workingDirectory, + maxLines: isCompact ? 2 : 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 16), + if (isCompact) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + onPressed: _openNewTerminal, + icon: const Icon(Icons.terminal), + label: const Text('Open terminal'), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _editProject(project), + icon: const Icon(Icons.edit_outlined), + label: const Text('Edit'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _deleteProject(project), + icon: const Icon(Icons.delete_outline), + label: const Text('Delete'), + ), + ), + ], + ), + ], + ) + else + Wrap( + spacing: 12, + runSpacing: 12, children: [ FilledButton.icon( onPressed: _openNewTerminal, icon: const Icon(Icons.terminal), label: const Text('Open terminal'), ), - const SizedBox(width: 12), OutlinedButton.icon( onPressed: () => _editProject(project), icon: const Icon(Icons.edit_outlined), label: const Text('Edit'), ), - const SizedBox(width: 12), OutlinedButton.icon( onPressed: () => _deleteProject(project), icon: const Icon(Icons.delete_outline), @@ -278,8 +316,7 @@ class _ProjectDetailPageState extends ConsumerState { ), ], ), - ], - ), + ], ), ), const SizedBox(height: 16), diff --git a/apps/mobile_app/lib/features/projects/project_list_page.dart b/apps/mobile_app/lib/features/projects/project_list_page.dart index 7bbd95d..a43815e 100644 --- a/apps/mobile_app/lib/features/projects/project_list_page.dart +++ b/apps/mobile_app/lib/features/projects/project_list_page.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../app/app_theme.dart'; +import '../../app/ui_shell.dart'; import '../../core/network/agent_connection_providers.dart'; import '../../core/network/agent_error_formatter.dart'; +import '../sessions/session.dart'; import '../sessions/session_list_page.dart'; import '../terminal/terminal_page.dart'; import 'project.dart'; @@ -17,6 +20,9 @@ class ProjectListPage extends ConsumerStatefulWidget { class _ProjectListPageState extends ConsumerState { late final TextEditingController _agentUrlController; + final Map> _detailFutures = + >{}; + final Set _expandedProjectIds = {}; @override void initState() { @@ -33,6 +39,7 @@ class _ProjectListPageState extends ConsumerState { } Future _reloadProjects() async { + _detailFutures.clear(); ref.invalidate(projectsProvider); try { await ref.read(projectsProvider.future); @@ -222,6 +229,7 @@ class _ProjectListPageState extends ConsumerState { ), ), ); + await _refreshProjectDetail(project.projectId); } catch (error) { if (!mounted) { return; @@ -233,19 +241,186 @@ class _ProjectListPageState extends ConsumerState { } } - Widget _buildAgentConfigCard(Uri baseUri) { - return Card( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Agent base URL', - style: Theme.of(context).textTheme.titleMedium, + Future _loadProjectDetail(String projectId) { + return ref.read(projectRepositoryProvider).getProjectDetail(projectId); + } + + Future _refreshProjectDetail(String projectId) async { + setState(() { + _detailFutures[projectId] = _loadProjectDetail(projectId); + }); + await _detailFutures[projectId]; + } + + Future _deleteSession(Project project, Session session) async { + final shouldDelete = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete session'), + content: Text('Delete "${session.name}" from "${project.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (shouldDelete != true) { + return; + } + + try { + await ref + .read(sessionRepositoryProvider) + .deleteSession(session.sessionId); + ref.invalidate(sessionsProvider); + await _refreshProjectDetail(project.projectId); + } catch (error) { + if (!mounted) { + return; + } + + _showMessage( + formatAgentError(error, fallback: 'Failed to delete session.'), + ); + } + } + + Future _openExistingSession(Project project, Session session) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => TerminalPage( + session: session, + agentBaseUri: ref.read(agentBaseUriProvider), + project: project, + ), + ), + ); + await _refreshProjectDetail(project.projectId); + } + + Widget _buildRecentSessions(Project project) { + final detailFuture = _detailFutures[project.projectId] ??= + _loadProjectDetail(project.projectId); + + return FutureBuilder( + future: detailFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.only(top: 12), + child: LinearProgressIndicator(minHeight: 2), + ); + } + + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + 'Recent sessions are unavailable.', + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } + + final sessions = snapshot.data?.recentSessions ?? const []; + if (sessions.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + 'No recent sessions yet.', + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } + + final isExpanded = _expandedProjectIds.contains(project.projectId); + final visibleSessions = isExpanded + ? sessions + : sessions.take(3).toList(); + final hasMore = sessions.length > visibleSessions.length; + + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recent sessions', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.secondary, + ), + ), + const SizedBox(height: 8), + for (final session in visibleSessions) ...[ + _ProjectSessionRow( + session: session, + onOpen: () => _openExistingSession(project, session), + onDelete: () => _deleteSession(project, session), + ), + if (session.sessionId != visibleSessions.last.sessionId) + const SizedBox(height: 6), + ], + if (hasMore) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () { + setState(() { + _expandedProjectIds.add(project.projectId); + }); + }, + child: const Text('Show more'), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildAgentConfigPanel(Uri baseUri) { + final isCompact = MediaQuery.sizeOf(context).width < 420; + + return AppPanel( + key: const Key('agent_connection_panel'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Agent base URL', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (isCompact) ...[ + TextField( + controller: _agentUrlController, + decoration: const InputDecoration( + hintText: 'http://10.0.2.2:5067', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _applyAgentUrl(), ), const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _applyAgentUrl, + child: const Text('Use'), + ), + ), + ] else Row( children: [ Expanded( @@ -267,18 +442,119 @@ class _ProjectListPageState extends ConsumerState { ), ], ), - const SizedBox(height: 8), - Text( - 'Project requests use this base origin: ${baseUri.toString()}.', - style: Theme.of(context).textTheme.bodySmall, - ), - ], + const SizedBox(height: 8), + Text( + 'Project requests use this base origin: ${baseUri.toString()}.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + Widget _buildProjectHeader() { + return Padding( + key: const Key('project_page_header'), + padding: AppTheme.pagePadding.copyWith(bottom: 0), + child: const AppSectionHeader( + eyebrow: 'Workspace', + title: 'Launch terminals in known working directories.', + subtitle: 'Stored terminal workspaces', + ), + ); + } + + Widget _buildProjectTile(Project project) { + final isCompact = MediaQuery.sizeOf(context).width < 420; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: () => _openProject(project), + child: AppPanel( + key: Key('project_tile_${project.projectId}'), + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.folder_copy_outlined), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + project.workingDirectory, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: isCompact ? 2 : 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + key: Key('project_delete_button_${project.projectId}'), + tooltip: 'Delete project', + onPressed: () => _deleteProject(project), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + const SizedBox(height: 16), + if (isCompact) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton( + onPressed: () => _openTerminal(project), + child: const Text('Open terminal'), + ), + const SizedBox(height: 10), + OutlinedButton( + onPressed: () => _showProjectEditor(existing: project), + child: const Text('Edit'), + ), + ], + ) + else + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: () => _openTerminal(project), + child: const Text('Open terminal'), + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: () => _showProjectEditor(existing: project), + child: const Text('Edit'), + ), + ], + ), + _buildRecentSessions(project), + ], + ), ), ), ); } - Widget _buildProjectsBody(AsyncValue> projectsAsync) { + Widget _buildProjectsBody( + AsyncValue> projectsAsync, + Uri baseUri, + ) { return projectsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) { @@ -286,18 +562,27 @@ class _ProjectListPageState extends ConsumerState { onRefresh: _reloadProjects, child: ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ - const SizedBox(height: 96), - const Icon(Icons.folder_off_outlined, size: 48), + _buildProjectHeader(), const SizedBox(height: 16), - Text( - 'Could not load projects', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, + _buildAgentConfigPanel(baseUri), + const SizedBox(height: 16), + AppPanel( + child: Column( + children: [ + const Icon(Icons.folder_off_outlined, size: 48), + const SizedBox(height: 16), + Text( + 'Could not load projects', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text('$error', textAlign: TextAlign.center), + ], + ), ), - const SizedBox(height: 8), - Text('$error', textAlign: TextAlign.center), ], ), ); @@ -305,65 +590,30 @@ class _ProjectListPageState extends ConsumerState { data: (projects) { return RefreshIndicator( onRefresh: _reloadProjects, - child: projects.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(24), - children: [ - const SizedBox(height: 56), - Icon( - Icons.folder_open, - size: 56, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: 16), - Text( - 'No projects yet', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + _buildProjectHeader(), + const SizedBox(height: 16), + _buildAgentConfigPanel(baseUri), + const SizedBox(height: 16), + if (projects.isEmpty) + const AppEmptyState( + icon: Icons.folder_open, + title: 'No projects yet', + message: 'Add a project to launch new terminals in a known working directory.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], ) - : ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - itemCount: projects.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final project = projects[index]; - return Card( - child: ListTile( - onTap: () => _openProject(project), - leading: const Icon(Icons.folder_copy_outlined), - title: Text(project.name), - subtitle: Text(project.workingDirectory), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - key: Key( - 'project_delete_button_${project.projectId}', - ), - tooltip: 'Delete project', - onPressed: () => _deleteProject(project), - icon: const Icon(Icons.delete_outline), - ), - FilledButton( - onPressed: () => _openTerminal(project), - child: const Text('Open terminal'), - ), - ], - ), - ), - ); - }, - ), + else + ...List.generate(projects.length * 2 - 1, (index) { + if (index.isEven) { + return _buildProjectTile(projects[index ~/ 2]); + } + return const SizedBox(height: 12); + }), + ], + ), ); }, ); @@ -401,11 +651,72 @@ class _ProjectListPageState extends ConsumerState { tooltip: 'Create project', child: const Icon(Icons.add), ), - body: Column( - children: [ - _buildAgentConfigCard(baseUri), - Expanded(child: _buildProjectsBody(projectsAsync)), - ], + body: _buildProjectsBody(projectsAsync, baseUri), + ); + } +} + +class _ProjectSessionRow extends StatelessWidget { + const _ProjectSessionRow({ + required this.session, + required this.onOpen, + required this.onDelete, + }); + + final Session session; + final VoidCallback onOpen; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 6, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 1), + Text( + 'Status: ${session.status}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(width: 8), + TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(0, 36), + padding: const EdgeInsets.symmetric(horizontal: 10), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: onOpen, + child: const Text('Open'), + ), + IconButton( + key: Key('project_session_delete_${session.sessionId}'), + tooltip: 'Delete session', + onPressed: onDelete, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.delete_outline), + ), + ], + ), ), ); } diff --git a/apps/mobile_app/lib/features/sessions/session_list_page.dart b/apps/mobile_app/lib/features/sessions/session_list_page.dart index 2334616..c8105d7 100644 --- a/apps/mobile_app/lib/features/sessions/session_list_page.dart +++ b/apps/mobile_app/lib/features/sessions/session_list_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../app/app_theme.dart'; +import '../../app/ui_shell.dart'; import '../../core/network/agent_connection_providers.dart'; import 'session.dart'; import '../terminal/terminal_page.dart'; @@ -178,19 +180,38 @@ class _SessionListPageState extends ConsumerState { } } - Widget _buildAgentConfigCard(Uri baseUri) { - return Card( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Agent base URL', - style: Theme.of(context).textTheme.titleMedium, + Widget _buildAgentConfigPanel(Uri baseUri) { + final isCompact = MediaQuery.sizeOf(context).width < 420; + + return AppPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Agent base URL', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (isCompact) ...[ + TextField( + controller: _agentUrlController, + decoration: const InputDecoration( + hintText: 'http://10.0.2.2:5067', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _applyAgentUrl(), ), const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _applyAgentUrl, + child: const Text('Use'), + ), + ), + ] else Row( children: [ Expanded( @@ -212,18 +233,102 @@ class _SessionListPageState extends ConsumerState { ), ], ), - const SizedBox(height: 8), - Text( - 'Session requests use this base origin: ${baseUri.toString()}.', - style: Theme.of(context).textTheme.bodySmall, - ), - ], + const SizedBox(height: 8), + Text( + 'Session requests use this base origin: ${baseUri.toString()}.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + Widget _buildSessionHeader() { + return Padding( + key: const Key('session_page_header'), + padding: AppTheme.pagePadding.copyWith(bottom: 0), + child: const AppSectionHeader( + eyebrow: 'Runtime', + title: 'Reusable terminal sessions', + subtitle: 'tied to the current agent.', + ), + ); + } + + Widget _buildSessionTile(Session session) { + final isCompact = MediaQuery.sizeOf(context).width < 420; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: () => _openSession(session), + child: AppPanel( + key: Key('session_tile_${session.sessionId}'), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.memory_outlined), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.name, + style: Theme.of(context).textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'Status: ${session.status}', + maxLines: isCompact ? 2 : 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + key: Key('session_delete_button_${session.sessionId}'), + tooltip: 'Delete session', + onPressed: () => _deleteSession(session), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + const SizedBox(height: 14), + if (isCompact) + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => _openSession(session), + child: const Text('Open'), + ), + ), + if (!isCompact) + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: () => _openSession(session), + child: const Text('Open'), + ), + ), + ], + ), ), ), ); } - Widget _buildSessionsBody(AsyncValue> sessionsAsync) { + Widget _buildSessionsBody( + AsyncValue> sessionsAsync, + Uri baseUri, + ) { return sessionsAsync.when( loading: () { return const Center(child: CircularProgressIndicator()); @@ -233,18 +338,27 @@ class _SessionListPageState extends ConsumerState { onRefresh: _refreshSessions, child: ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ - const SizedBox(height: 96), - const Icon(Icons.cloud_off_outlined, size: 48), + _buildSessionHeader(), const SizedBox(height: 16), - Text( - 'Could not load sessions', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, + _buildAgentConfigPanel(baseUri), + const SizedBox(height: 16), + AppPanel( + child: Column( + children: [ + const Icon(Icons.cloud_off_outlined, size: 48), + const SizedBox(height: 16), + Text( + 'Could not load sessions', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text('$error', textAlign: TextAlign.center), + ], + ), ), - const SizedBox(height: 8), - Text('$error', textAlign: TextAlign.center), ], ), ); @@ -252,67 +366,30 @@ class _SessionListPageState extends ConsumerState { data: (sessions) { return RefreshIndicator( onRefresh: _refreshSessions, - child: sessions.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(24), - children: [ - const SizedBox(height: 56), - Icon( - Icons.terminal_outlined, - size: 56, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: 16), - Text( - 'No sessions yet', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + _buildSessionHeader(), + const SizedBox(height: 16), + _buildAgentConfigPanel(baseUri), + const SizedBox(height: 16), + if (sessions.isEmpty) + const AppEmptyState( + icon: Icons.terminal_outlined, + title: 'No sessions yet', + message: 'Create a session after you point the app at a reachable agent.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], ) - : ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - itemCount: sessions.length, - separatorBuilder: (context, index) => - const SizedBox(height: 12), - itemBuilder: (context, index) { - final session = sessions[index]; - return Card( - child: ListTile( - onTap: () => _openSession(session), - leading: const Icon(Icons.terminal), - title: Text(session.name), - subtitle: Text('Status: ${session.status}'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - session.sessionId, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(width: 8), - IconButton( - key: Key( - 'session_delete_button_${session.sessionId}', - ), - tooltip: 'Delete session', - onPressed: () => _deleteSession(session), - icon: const Icon(Icons.delete_outline), - ), - ], - ), - ), - ); - }, - ), + else + ...List.generate(sessions.length * 2 - 1, (index) { + if (index.isEven) { + return _buildSessionTile(sessions[index ~/ 2]); + } + return const SizedBox(height: 12); + }), + ], + ), ); }, ); @@ -339,12 +416,7 @@ class _SessionListPageState extends ConsumerState { tooltip: 'Create session', child: const Icon(Icons.add), ), - body: Column( - children: [ - _buildAgentConfigCard(baseUri), - Expanded(child: _buildSessionsBody(sessionsAsync)), - ], - ), + body: _buildSessionsBody(sessionsAsync, baseUri), ); } } diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart index f1b3464..7d22bd0 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xterm/xterm.dart'; +import '../../app/app_theme.dart'; +import '../../app/ui_shell.dart'; import '../../core/network/agent_connection_providers.dart'; import '../../core/network/agent_error_formatter.dart'; import '../projects/project.dart'; @@ -288,7 +290,7 @@ class _TerminalPageState extends ConsumerState { style: Theme.of(context).textTheme.titleMedium, ), const Spacer(), - _StatusChip( + StatusPill( label: _statusLabel, icon: _statusIcon, color: _statusColor(context), @@ -354,17 +356,29 @@ class _TerminalPageState extends ConsumerState { @override Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final isCompact = width < 420; + final workingDirectory = + widget.project?.workingDirectory ?? + widget.session.workingDirectory ?? + ''; + return Scaffold( appBar: AppBar( + toolbarHeight: 44, + titleSpacing: 0, title: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(widget.session.name), Text( - widget.project?.workingDirectory ?? - widget.session.workingDirectory ?? - '', + widget.session.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + workingDirectory, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, @@ -378,377 +392,439 @@ class _TerminalPageState extends ConsumerState { final mode = controller.isFollowingLiveOutput ? 'Live' : 'Scrollback'; + final modeLabel = '$mode | ${controller.liveLines.length} lines'; + return Row( mainAxisSize: MainAxisSize.min, children: [ + TextButton( + onPressed: controller.isFollowingLiveOutput + ? controller.enterScrollback + : controller.jumpToLive, + style: TextButton.styleFrom( + minimumSize: const Size(0, 32), + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: Text(modeLabel), + ), + const SizedBox(width: 4), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: InkWell( - borderRadius: BorderRadius.circular(999), - onTap: () { - if (controller.isFollowingLiveOutput) { - controller.enterScrollback(); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 6, - ), - child: Text( - '$mode | ${controller.liveLines.length} lines', - ), + padding: const EdgeInsets.only(right: 8), + child: Center( + child: StatusPill( + label: _statusLabel, + icon: _statusIcon, + color: _statusColor(context), ), ), ), - if (!controller.isFollowingLiveOutput) - Padding( - padding: const EdgeInsets.only(right: 12), - child: TextButton( - onPressed: controller.jumpToLive, - child: const Text('Back to live'), - ), - ), ], ); }, ), ], ), - body: Column( - children: [ - AnimatedBuilder( - animation: _controllerAndCoordinator, - builder: (context, _) { - if (controller.isFollowingLiveOutput) { - return const SizedBox.shrink(); - } + body: Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 10), + child: Column( + children: [ + _buildScrollbackSection(context, isCompact), + Expanded( + child: Container( + key: const Key('terminal_surface_panel'), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.zero, + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: TerminalView( + terminal, + focusNode: _terminalFocusNode, + autofocus: false, + scrollController: _terminalScrollController, + ), + ), + ), + const SizedBox(height: 6), + _buildCommandDeck(context, isCompact), + ], + ), + ), + ); + } - return Flexible( - fit: FlexFit.loose, - child: SingleChildScrollView( - padding: const EdgeInsets.only(top: 6), + Widget _buildScrollbackSection(BuildContext context, bool isCompact) { + return AnimatedBuilder( + animation: _controllerAndCoordinator, + builder: (context, _) { + if (controller.isFollowingLiveOutput) { + return const SizedBox.shrink(); + } + + return Flexible( + fit: FlexFit.loose, + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + children: [ + AppPanel( + padding: const EdgeInsets.all(8), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: double.infinity, - margin: const EdgeInsets.fromLTRB(12, 0, 12, 0), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - child: Column( + if (isCompact) + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text( - 'Recent scrollback', - style: Theme.of(context).textTheme.titleSmall, - ), - const Spacer(), - Text( - '${controller.historyWindow.lines.length} lines loaded', - style: Theme.of( - context, - ).textTheme.labelMedium, - ), - ], + Text( + 'Recent scrollback', + style: Theme.of(context).textTheme.titleSmall, ), - const SizedBox(height: 6), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 72), - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - ), - child: ListView.separated( - key: const Key('terminal_scrollback_list'), - shrinkWrap: true, - itemCount: - controller.historyWindow.lines.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - child: Text( - controller.historyWindow.lines[index], - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - ); - }, - separatorBuilder: (context, index) => Divider( - height: 1, - color: Theme.of( - context, - ).colorScheme.outlineVariant, - ), - ), - ), + const SizedBox(height: 4), + Text( + '${controller.historyWindow.lines.length} lines loaded', + style: Theme.of(context).textTheme.labelMedium, ), - const SizedBox(height: 6), - Container( - key: const Key('terminal_scrollback_actions'), - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Expanded( - child: Text( - controller.historyWindow.hasMoreAbove - ? 'Recent history is loaded. Older lines are not loaded yet.' - : 'All loaded history is visible.', - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - ), - if (controller - .historyWindow - .hasMoreAbove) ...[ - const SizedBox(width: 8), - TextButton.icon( - onPressed: - _coordinator.isLoadingOlderHistory - ? null - : _coordinator.loadOlderHistory, - icon: _coordinator.isLoadingOlderHistory - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon( - Icons.unfold_less_double, - ), - label: Text( - _coordinator.isLoadingOlderHistory - ? 'Loading older lines...' - : 'Load older lines', - ), - ), - ], - ], - ), + ], + ) + else + Row( + children: [ + Text( + 'Recent scrollback', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + Text( + '${controller.historyWindow.lines.length} lines loaded', + style: Theme.of(context).textTheme.labelMedium, ), ], ), + const SizedBox(height: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 64), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.zero, + ), + child: ListView.separated( + key: const Key('terminal_scrollback_list'), + shrinkWrap: true, + itemCount: controller.historyWindow.lines.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Text( + controller.historyWindow.lines[index], + style: Theme.of(context).textTheme.bodySmall, + ), + ); + }, + separatorBuilder: (context, index) => Divider( + height: 1, + color: Theme.of( + context, + ).colorScheme.outlineVariant, + ), + ), + ), ), + const SizedBox(height: 4), Container( + key: const Key('terminal_scrollback_actions'), width: double.infinity, - margin: const EdgeInsets.fromLTRB(12, 4, 12, 0), padding: const EdgeInsets.symmetric( - horizontal: 12, + horizontal: 10, vertical: 8, ), decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.zero, ), - child: Row( + child: isCompact + ? _buildCompactHistoryActions(context) + : _buildWideHistoryActions(context), + ), + ], + ), + ), + const SizedBox(height: 6), + AppPanel( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + borderRadius: BorderRadius.zero, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.pause_circle_outline, + size: 18, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.pause_circle_outline, - size: 18, - color: Theme.of( - context, - ).colorScheme.onSecondaryContainer, + Text( + 'Browsing history. Live output is still arriving.', + style: Theme.of(context).textTheme.bodySmall, ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Browsing history. Live output is still arriving.', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSecondaryContainer, - ), - ), - if (controller.hasPendingLiveOutput) - Align( - alignment: Alignment.centerLeft, - child: TextButton( - onPressed: controller.jumpToLive, - child: const Text( - 'New output available', - ), - ), - ), - ], + if (controller.hasPendingLiveOutput) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: controller.jumpToLive, + child: const Text('New output available'), + ), ), - ), ], ), ), ], ), ), - ); - }, - ), - Expanded( - child: TerminalView( - terminal, - focusNode: _terminalFocusNode, - autofocus: false, - scrollController: _terminalScrollController, + ], ), ), - Material( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), - child: AnimatedBuilder( - animation: _controllerAndCoordinator, - builder: (context, _) { - return Container( - key: const Key('terminal_input_bar'), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: Theme.of(context).colorScheme.outlineVariant, - ), - boxShadow: [ - BoxShadow( - color: Theme.of( - context, - ).shadowColor.withValues(alpha: 0.06), - blurRadius: 12, - offset: const Offset(0, 6), - ), - ], - ), - child: Column( - key: const Key('terminal_action_bar'), - mainAxisSize: MainAxisSize.min, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _quickTerminalKeys - .map((quickKey) { - return Padding( - padding: const EdgeInsets.only(right: 8), - child: OutlinedButton( - key: Key( - 'terminal_quick_key_${quickKey.keyId}', - ), - onPressed: _canSendInput - ? () => _sendQuickKey(quickKey) - : null, - child: Text(quickKey.label), - ), - ); - }) - .toList(growable: false), - ), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: _inputController, - focusNode: _inputFocusNode, - enabled: _canSendInput, - decoration: const InputDecoration( - isDense: true, - hintText: 'Send input', - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 10, - ), - ), - onSubmitted: (_) => _sendLine(), - ), - ), - const SizedBox(width: 8), - FilledButton( - key: const Key('terminal_send_button'), - onPressed: _canSendInput ? _sendLine : null, - style: FilledButton.styleFrom( - minimumSize: const Size(0, 44), - padding: const EdgeInsets.symmetric( - horizontal: 14, - ), - ), - child: const Text('Send'), - ), - const SizedBox(width: 8), - IconButton.filledTonal( - key: const Key('terminal_direct_input_toggle'), - onPressed: _canSendInput - ? _toggleDirectInput - : null, - icon: Icon( - _isDirectInputEnabled - ? Icons.keyboard_hide - : Icons.keyboard, - ), - tooltip: _isDirectInputEnabled - ? 'Disable direct input' - : 'Enable direct input', - ), - const SizedBox(width: 8), - IconButton.filledTonal( - key: const Key('terminal_toggle_actions_button'), - onPressed: _showToolsSheet, - icon: const Icon(Icons.tune), - tooltip: 'Show tools', - ), - ], - ), - const SizedBox(height: 6), - Align( - alignment: Alignment.centerLeft, - child: Text( - _isDirectInputEnabled - ? 'Direct input on' - : 'Browse mode', - key: const Key('terminal_input_mode_label'), - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - ); - }, - ), + ); + }, + ); + } + + Widget _buildCompactHistoryActions(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.historyWindow.hasMoreAbove + ? 'Recent history is loaded. Older lines are not loaded yet.' + : 'All loaded history is visible.', + style: Theme.of(context).textTheme.bodySmall, + ), + if (controller.historyWindow.hasMoreAbove) ...[ + const SizedBox(height: 8), + TextButton.icon( + onPressed: _coordinator.isLoadingOlderHistory + ? null + : _coordinator.loadOlderHistory, + icon: _coordinator.isLoadingOlderHistory + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.unfold_less_double), + label: Text( + _coordinator.isLoadingOlderHistory + ? 'Loading older lines...' + : 'Load older lines', ), ), ], + ], + ); + } + + Widget _buildWideHistoryActions(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + controller.historyWindow.hasMoreAbove + ? 'Recent history is loaded. Older lines are not loaded yet.' + : 'All loaded history is visible.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (controller.historyWindow.hasMoreAbove) ...[ + const SizedBox(width: 8), + TextButton.icon( + onPressed: _coordinator.isLoadingOlderHistory + ? null + : _coordinator.loadOlderHistory, + icon: _coordinator.isLoadingOlderHistory + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.unfold_less_double), + label: Text( + _coordinator.isLoadingOlderHistory + ? 'Loading older lines...' + : 'Load older lines', + ), + ), + ], + ], + ); + } + + Widget _buildCommandDeck(BuildContext context, bool isCompact) { + return AppPanel( + key: const Key('terminal_command_deck'), + padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + borderRadius: BorderRadius.zero, + child: AnimatedBuilder( + animation: _controllerAndCoordinator, + builder: (context, _) { + return Column( + key: const Key('terminal_action_bar'), + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _quickTerminalKeys + .map((quickKey) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: OutlinedButton( + key: Key('terminal_quick_key_${quickKey.keyId}'), + onPressed: _canSendInput + ? () => _sendQuickKey(quickKey) + : null, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 34), + padding: const EdgeInsets.symmetric( + horizontal: 10, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: Text(quickKey.label), + ), + ); + }) + .toList(growable: false), + ), + ), + const SizedBox(height: 4), + if (isCompact) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _inputController, + focusNode: _inputFocusNode, + enabled: _canSendInput, + decoration: const InputDecoration( + hintText: 'Send command or input', + ), + onSubmitted: (_) => _sendLine(), + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: FilledButton( + key: const Key('terminal_send_button'), + onPressed: _canSendInput ? _sendLine : null, + style: FilledButton.styleFrom( + minimumSize: const Size(0, 38), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: const Text('Send'), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + key: const Key('terminal_direct_input_toggle'), + onPressed: _canSendInput ? _toggleDirectInput : null, + visualDensity: VisualDensity.compact, + icon: Icon( + _isDirectInputEnabled + ? Icons.keyboard_hide + : Icons.keyboard, + ), + tooltip: _isDirectInputEnabled + ? 'Disable direct input' + : 'Enable direct input', + ), + const SizedBox(width: 8), + IconButton.filledTonal( + key: const Key('terminal_toggle_actions_button'), + onPressed: _showToolsSheet, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.tune), + tooltip: 'Show tools', + ), + ], + ), + ], + ) + else + Row( + children: [ + Expanded( + child: TextField( + controller: _inputController, + focusNode: _inputFocusNode, + enabled: _canSendInput, + decoration: const InputDecoration( + hintText: 'Send command or input', + ), + onSubmitted: (_) => _sendLine(), + ), + ), + const SizedBox(width: 8), + FilledButton( + key: const Key('terminal_send_button'), + onPressed: _canSendInput ? _sendLine : null, + style: FilledButton.styleFrom( + minimumSize: const Size(0, 38), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: const Text('Send'), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + key: const Key('terminal_direct_input_toggle'), + onPressed: _canSendInput ? _toggleDirectInput : null, + visualDensity: VisualDensity.compact, + icon: Icon( + _isDirectInputEnabled + ? Icons.keyboard_hide + : Icons.keyboard, + ), + tooltip: _isDirectInputEnabled + ? 'Disable direct input' + : 'Enable direct input', + ), + const SizedBox(width: 8), + IconButton.filledTonal( + key: const Key('terminal_toggle_actions_button'), + onPressed: _showToolsSheet, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.tune), + tooltip: 'Show tools', + ), + ], + ), + const SizedBox(height: 4), + Text( + _isDirectInputEnabled ? 'Direct input enabled' : 'Browse mode', + key: const Key('terminal_input_mode_label'), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + }, ), ); } @@ -794,43 +870,3 @@ class _QuickTerminalKey { final String label; final String input; } - -class _StatusChip extends StatelessWidget { - const _StatusChip({ - required this.label, - required this.icon, - required this.color, - }); - - final String label; - final IconData icon; - final Color color; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.24)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 6), - Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: color, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ); - } -} diff --git a/apps/mobile_app/test/project_home_test.dart b/apps/mobile_app/test/project_home_test.dart index 9498d97..885994d 100644 --- a/apps/mobile_app/test/project_home_test.dart +++ b/apps/mobile_app/test/project_home_test.dart @@ -38,8 +38,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Projects'), findsOneWidget); + expect(find.byKey(const Key('project_page_header')), findsOneWidget); expect(find.text('TermRemoteCtl'), findsOneWidget); expect(find.text(r'C:\repo\termremotectl'), findsOneWidget); + expect( + find.text('Launch terminals in known working directories.'), + findsOneWidget, + ); + expect(find.text('Stored terminal workspaces'), findsOneWidget); + expect(find.byKey(const Key('project_tile_project-1')), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, 'Open terminal')); await tester.pumpAndSettle(); diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index a6eb95a..0d1a131 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -8,6 +8,7 @@ import 'package:term_remote_ctl/app/app.dart'; import 'package:term_remote_ctl/core/network/agent_api_client.dart'; import 'package:term_remote_ctl/core/network/agent_connection_providers.dart'; import 'package:term_remote_ctl/features/projects/project.dart'; +import 'package:term_remote_ctl/features/projects/project_detail_page.dart'; import 'package:term_remote_ctl/features/projects/project_repository.dart'; import 'package:term_remote_ctl/features/sessions/session.dart'; import 'package:term_remote_ctl/features/sessions/session_list_page.dart'; @@ -23,11 +24,15 @@ void main() { sessionRepository: _FakeSessionRepository(), ); + final materialApp = tester.widget(find.byType(MaterialApp)); final agentUrlField = tester.widget( find.byType(TextField).first, ); expect(find.text('Projects'), findsOneWidget); + expect(materialApp.theme?.brightness, Brightness.dark); + expect(materialApp.theme?.scaffoldBackgroundColor, isNot(Colors.white)); + expect(find.byKey(const Key('project_page_header')), findsOneWidget); expect(find.text('Agent base URL'), findsOneWidget); expect(agentUrlField.controller?.text, 'http://10.0.2.2:5067'); expect(agentUrlField.decoration?.hintText, 'http://10.0.2.2:5067'); @@ -37,6 +42,12 @@ void main() { ); expect(find.text('codex-main'), findsOneWidget); expect(find.text(r'C:\repo\codex-main'), findsOneWidget); + expect( + find.text('Launch terminals in known working directories.'), + findsOneWidget, + ); + expect(find.text('Stored terminal workspaces'), findsOneWidget); + expect(find.byKey(const Key('project_tile_project-1')), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Open terminal'), findsOneWidget); }); @@ -56,6 +67,8 @@ void main() { expect(sessionRepository.lastCreatedProjectId, 'project-1'); expect(find.text('codex-main'), findsOneWidget); expect(find.text(r'C:\repo\codex-main'), findsOneWidget); + expect(find.byKey(const Key('terminal_surface_panel')), findsOneWidget); + expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget); expect( find.byKey(const Key('terminal_toggle_actions_button')), findsOneWidget, @@ -92,6 +105,103 @@ void main() { expect(find.text('codex-main'), findsNothing); }); + testWidgets( + 'project card shows recent sessions, expands, and deletes a single session', + (tester) async { + final projectRepository = _FakeProjectRepository.withRecentSessions({ + 'project-1': [ + _session('session-1', 'alpha'), + _session('session-2', 'beta'), + _session('session-3', 'gamma'), + _session('session-4', 'delta'), + ], + }); + final sessionRepository = _FakeSessionRepository.withSessions([ + _session('session-1', 'alpha'), + _session('session-2', 'beta'), + _session('session-3', 'gamma'), + _session('session-4', 'delta'), + ])..onDeleteSession = projectRepository.removeRecentSession; + + await _pumpApp( + tester, + projectRepository: projectRepository, + sessionRepository: sessionRepository, + ); + + expect(find.text('alpha'), findsOneWidget); + expect(find.text('beta'), findsOneWidget); + expect(find.text('gamma'), findsOneWidget); + expect(find.text('delta'), findsNothing); + expect(find.text('Recent sessions'), findsOneWidget); + expect(find.text('Show more'), findsOneWidget); + + await tester.ensureVisible(find.text('Show more')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Show more')); + await tester.pumpAndSettle(); + + expect(find.text('delta'), findsOneWidget); + + await tester.ensureVisible( + find.byKey(const Key('project_session_delete_session-2')), + ); + await tester.tap( + find.byKey(const Key('project_session_delete_session-2')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(sessionRepository.deletedSessionIds, contains('session-2')); + expect(find.text('beta'), findsNothing); + }, + ); + + testWidgets('project detail page avoids overflow on narrow screens', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(393, 852); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + projectRepositoryProvider.overrideWithValue( + _FakeProjectRepository.withRecentSessions({ + 'project-1': [_session('session-1', 'alpha')], + }), + ), + sessionRepositoryProvider.overrideWithValue(_FakeSessionRepository()), + terminalSocketSessionFactoryProvider.overrideWithValue( + TerminalSocketSessionFactory( + transportFactory: (_) => + _FakeTerminalSocketTransport(autoAttach: true), + ), + ), + ], + child: MaterialApp( + home: ProjectDetailPage( + project: Project( + projectId: 'project-1', + name: 'tt', + workingDirectory: r'D:\App\python\robot_face_rec', + createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'), + updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Recent sessions'), findsOneWidget); + }); + testWidgets( 'terminal page keeps tools hidden until the user opens the tools sheet', (tester) async { @@ -110,7 +220,7 @@ void main() { ); expect(find.byKey(const Key('terminal_action_bar')), findsOneWidget); expect(find.byKey(const Key('terminal_send_button')), findsOneWidget); - expect(find.byKey(const Key('terminal_input_bar')), findsOneWidget); + expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget); expect(find.text('Browse mode'), findsOneWidget); await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); @@ -167,12 +277,12 @@ void main() { await _openProjectTerminal(tester); expect(find.text('Browse mode'), findsOneWidget); - expect(find.text('Direct input on'), findsNothing); + expect(find.text('Direct input enabled'), findsNothing); await tester.tap(find.byKey(const Key('terminal_direct_input_toggle'))); await tester.pumpAndSettle(); - expect(find.text('Direct input on'), findsOneWidget); + expect(find.text('Direct input enabled'), findsOneWidget); expect(find.text('Browse mode'), findsNothing); await tester.tap(find.byKey(const Key('terminal_direct_input_toggle'))); @@ -323,12 +433,12 @@ void main() { await _openProjectTerminal(tester); - expect(find.text('Back to live'), findsNothing); + expect(find.text('Scrollback | 2 lines'), findsNothing); await tester.tap(find.text('Live | 2 lines')); await tester.pumpAndSettle(); - expect(find.text('Back to live'), findsOneWidget); + expect(find.text('Scrollback | 2 lines'), findsOneWidget); expect(find.text('Load older lines'), findsOneWidget); await tester.ensureVisible(find.text('Load older lines')); @@ -364,7 +474,10 @@ void main() { ); await tester.pumpAndSettle(); + expect(find.byKey(const Key('session_page_header')), findsOneWidget); expect(find.text('codex-main'), findsOneWidget); + expect(find.text('Reusable terminal sessions'), findsOneWidget); + expect(find.byKey(const Key('session_tile_session-1')), findsOneWidget); await tester.tap(find.byKey(const Key('session_delete_button_session-1'))); await tester.pumpAndSettle(); @@ -421,13 +534,33 @@ class _FakeProjectRepository extends ProjectRepository { updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'), ), ], + _recentSessionsByProject = const {}, super(_FakeAgentApiClient()); _FakeProjectRepository.withProjects(List projects) : _projects = List.of(projects), + _recentSessionsByProject = const {}, + super(_FakeAgentApiClient()); + + _FakeProjectRepository.withRecentSessions( + Map> recentSessionsByProject, + ) : _projects = [ + Project( + projectId: 'project-1', + name: 'codex-main', + workingDirectory: r'C:\repo\codex-main', + createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'), + updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'), + ), + ], + _recentSessionsByProject = { + for (final entry in recentSessionsByProject.entries) + entry.key: List.of(entry.value), + }, super(_FakeAgentApiClient()); final List _projects; + final Map> _recentSessionsByProject; final List deletedProjectIds = []; @override @@ -435,9 +568,14 @@ class _FakeProjectRepository extends ProjectRepository { @override Future getProjectDetail(String projectId) async { + final project = _projects.singleWhere( + (entry) => entry.projectId == projectId, + ); return ProjectDetail( - project: _projects.single, - recentSessions: const [], + project: project, + recentSessions: List.of( + _recentSessionsByProject[projectId] ?? const [], + ), ); } @@ -446,6 +584,12 @@ class _FakeProjectRepository extends ProjectRepository { deletedProjectIds.add(projectId); _projects.removeWhere((project) => project.projectId == projectId); } + + void removeRecentSession(String sessionId) { + for (final sessions in _recentSessionsByProject.values) { + sessions.removeWhere((session) => session.sessionId == sessionId); + } + } } class _FakeSessionRepository extends SessionRepository { @@ -459,6 +603,7 @@ class _FakeSessionRepository extends SessionRepository { String? lastCreatedProjectId; int createCount = 0; + void Function(String sessionId)? onDeleteSession; final List deletedSessionIds = []; final List _sessions; @@ -488,9 +633,22 @@ class _FakeSessionRepository extends SessionRepository { Future deleteSession(String sessionId) async { deletedSessionIds.add(sessionId); _sessions.removeWhere((session) => session.sessionId == sessionId); + onDeleteSession?.call(sessionId); } } +Session _session(String sessionId, String name) { + return Session( + sessionId: sessionId, + name: name, + status: 'created', + projectId: 'project-1', + workingDirectory: r'C:\repo\codex-main', + createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'), + updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'), + ); +} + class _FailingSessionRepository extends SessionRepository { _FailingSessionRepository() : super(_FakeAgentApiClient());