feat: polish mobile terminal and session management

This commit is contained in:
sladro 2026-04-01 16:56:19 +08:00
parent 4a18707de3
commit d5bc0784f9
9 changed files with 1475 additions and 580 deletions

View File

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

View File

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

View File

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

View File

@ -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<ProjectDetailPage> {
@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<ProjectDetailPage> {
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<ProjectDetailPage> {
),
],
),
],
),
],
),
),
const SizedBox(height: 16),

View File

@ -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<ProjectListPage> {
late final TextEditingController _agentUrlController;
final Map<String, Future<ProjectDetail>> _detailFutures =
<String, Future<ProjectDetail>>{};
final Set<String> _expandedProjectIds = <String>{};
@override
void initState() {
@ -33,6 +39,7 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
}
Future<void> _reloadProjects() async {
_detailFutures.clear();
ref.invalidate(projectsProvider);
try {
await ref.read(projectsProvider.future);
@ -222,6 +229,7 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
),
),
);
await _refreshProjectDetail(project.projectId);
} catch (error) {
if (!mounted) {
return;
@ -233,19 +241,186 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
}
}
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<ProjectDetail> _loadProjectDetail(String projectId) {
return ref.read(projectRepositoryProvider).getProjectDetail(projectId);
}
Future<void> _refreshProjectDetail(String projectId) async {
setState(() {
_detailFutures[projectId] = _loadProjectDetail(projectId);
});
await _detailFutures[projectId];
}
Future<void> _deleteSession(Project project, Session session) async {
final shouldDelete = await showDialog<bool>(
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<void> _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<ProjectDetail>(
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 <Session>[];
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<ProjectListPage> {
),
],
),
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<List<Project>> projectsAsync) {
Widget _buildProjectsBody(
AsyncValue<List<Project>> projectsAsync,
Uri baseUri,
) {
return projectsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) {
@ -286,18 +562,27 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
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<ProjectListPage> {
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<Widget>.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<ProjectListPage> {
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),
),
],
),
),
);
}

View File

@ -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<SessionListPage> {
}
}
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<SessionListPage> {
),
],
),
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<List<Session>> sessionsAsync) {
Widget _buildSessionsBody(
AsyncValue<List<Session>> sessionsAsync,
Uri baseUri,
) {
return sessionsAsync.when(
loading: () {
return const Center(child: CircularProgressIndicator());
@ -233,18 +338,27 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
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<SessionListPage> {
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<Widget>.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<SessionListPage> {
tooltip: 'Create session',
child: const Icon(Icons.add),
),
body: Column(
children: [
_buildAgentConfigCard(baseUri),
Expanded(child: _buildSessionsBody(sessionsAsync)),
],
),
body: _buildSessionsBody(sessionsAsync, baseUri),
);
}
}

View File

@ -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<TerminalPage> {
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<TerminalPage> {
@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<TerminalPage> {
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,
),
),
],
),
),
);
}
}

View File

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

View File

@ -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<MaterialApp>(find.byType(MaterialApp));
final agentUrlField = tester.widget<TextField>(
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<Project> projects)
: _projects = List<Project>.of(projects),
_recentSessionsByProject = const {},
super(_FakeAgentApiClient());
_FakeProjectRepository.withRecentSessions(
Map<String, List<Session>> 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<Session>.of(entry.value),
},
super(_FakeAgentApiClient());
final List<Project> _projects;
final Map<String, List<Session>> _recentSessionsByProject;
final List<String> deletedProjectIds = <String>[];
@override
@ -435,9 +568,14 @@ class _FakeProjectRepository extends ProjectRepository {
@override
Future<ProjectDetail> getProjectDetail(String projectId) async {
final project = _projects.singleWhere(
(entry) => entry.projectId == projectId,
);
return ProjectDetail(
project: _projects.single,
recentSessions: const <Session>[],
project: project,
recentSessions: List<Session>.of(
_recentSessionsByProject[projectId] ?? const <Session>[],
),
);
}
@ -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<String> deletedSessionIds = <String>[];
final List<Session> _sessions;
@ -488,9 +633,22 @@ class _FakeSessionRepository extends SessionRepository {
Future<void> 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());