feat: polish mobile terminal and session management
This commit is contained in:
parent
4a18707de3
commit
d5bc0784f9
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
139
apps/mobile_app/lib/app/app_theme.dart
Normal file
139
apps/mobile_app/lib/app/app_theme.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
apps/mobile_app/lib/app/ui_shell.dart
Normal file
133
apps/mobile_app/lib/app/ui_shell.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user