feat(ui): complete M1 Milestone - read-only Dashboard and Detail Pane with status.sh integration
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import '../status_script_locator.dart';
|
||||
|
||||
/// The single [SessionService] instance for this app run — owns the
|
||||
/// resolved path to `status.sh`.
|
||||
final sessionServiceProvider = Provider<SessionService>((ref) {
|
||||
return SessionService(statusScriptPath: locateStatusScript());
|
||||
});
|
||||
|
||||
/// Polling + stale/backoff (D6) sits in `mam_core`; this provider just wires
|
||||
/// it up. `apps/mam_desktop` does not reimplement the timer/backoff logic.
|
||||
final statusRepositoryProvider = Provider<StatusRepository>((ref) {
|
||||
return StatusRepository(sessionService: ref.watch(sessionServiceProvider));
|
||||
});
|
||||
|
||||
/// Reactive stream of [SessionsPoll] the UI subscribes to. 4s base interval
|
||||
/// on success, backing off to 6s/15s on repeated `status.sh` failures.
|
||||
final sessionsPollProvider = StreamProvider<SessionsPoll>((ref) {
|
||||
final repo = ref.watch(statusRepositoryProvider);
|
||||
return repo.watch();
|
||||
});
|
||||
|
||||
/// Currently selected row name for the Master-Detail layout.
|
||||
final selectedSessionNameProvider = StateProvider<String?>((ref) => null);
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Locates `status.sh` by walking up from the current working directory to
|
||||
/// the repo root (marked by `.git`), then descending a fixed path — robust
|
||||
/// regardless of which subdirectory `flutter run` happens to be invoked
|
||||
/// from, unlike a hardcoded relative path.
|
||||
String locateStatusScript() {
|
||||
var dir = Directory.current;
|
||||
while (!Directory('${dir.path}/.git').existsSync()) {
|
||||
final parent = dir.parent;
|
||||
if (parent.path == dir.path) {
|
||||
throw StateError(
|
||||
'Could not locate the repo root (.git) from ${Directory.current.path}. '
|
||||
'Run mam_desktop from within the multi-agent-mux repo checkout.',
|
||||
);
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
final scriptPath =
|
||||
'${dir.path}/.agents/skills/multi-agent-mux-status/scripts/status.sh';
|
||||
if (!File(scriptPath).existsSync()) {
|
||||
throw StateError('status.sh not found at $scriptPath');
|
||||
}
|
||||
return scriptPath;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
static const bgTop = Color(0xFF0B0F1A);
|
||||
static const bgBottom = Color(0xFF141B2E);
|
||||
static const accent = Color(0xFF7C5CFF);
|
||||
static const accent2 = Color(0xFF19C6E0);
|
||||
static const surface = Color(0xFF171E31);
|
||||
static const surfaceAlt = Color(0xFF1E2740);
|
||||
static const border = Color(0xFF2A3350);
|
||||
static const textPrimary = Color(0xFFEAF0FF);
|
||||
static const textSecondary = Color(0xFF93A0C4);
|
||||
static const danger = Color(0xFFFF5C7A);
|
||||
static const warning = Color(0xFFFFB454);
|
||||
static const success = Color(0xFF4CE0B3);
|
||||
}
|
||||
|
||||
const backgroundGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.bgTop, AppColors.bgBottom],
|
||||
);
|
||||
|
||||
ThemeData buildAppTheme() {
|
||||
final base = ThemeData(brightness: Brightness.dark, useMaterial3: true);
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: AppColors.bgTop,
|
||||
colorScheme: base.colorScheme.copyWith(
|
||||
primary: AppColors.accent,
|
||||
secondary: AppColors.accent2,
|
||||
surface: AppColors.surface,
|
||||
error: AppColors.danger,
|
||||
),
|
||||
textTheme: base.textTheme.apply(
|
||||
bodyColor: AppColors.textPrimary,
|
||||
displayColor: AppColors.textPrimary,
|
||||
),
|
||||
dividerColor: AppColors.border,
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class _KV {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool copyable;
|
||||
const _KV(this.label, this.value, {this.copyable = false});
|
||||
}
|
||||
|
||||
class DetailPane extends StatelessWidget {
|
||||
final SessionRow? session;
|
||||
const DetailPane({super.key, required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = session;
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.surface, AppColors.surfaceAlt],
|
||||
),
|
||||
border: Border(left: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: s == null ? const _EmptyDetail() : _DetailContent(session: s),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyDetail extends StatelessWidget {
|
||||
const _EmptyDetail();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Select a session to see details',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailContent extends StatelessWidget {
|
||||
final SessionRow session;
|
||||
const _DetailContent({required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = session;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Header(session: s),
|
||||
const SizedBox(height: 24),
|
||||
_Section(
|
||||
title: 'PANE',
|
||||
entries: [
|
||||
_KV('pid', s.pane.pid?.toString() ?? '?'),
|
||||
_KV('cwd', s.pane.cwd ?? '?'),
|
||||
_KV('cmd', s.pane.cmd ?? '?'),
|
||||
_KV('cmd_full', s.pane.cmdFull ?? '?', copyable: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Section(
|
||||
title: 'ATTACH',
|
||||
entries: [
|
||||
_KV('attach_command', s.attachCommand ?? '?', copyable: true),
|
||||
_KV('start_command', s.startCommand ?? '?', copyable: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Section(
|
||||
title: 'STATUS',
|
||||
entries: [
|
||||
_KV('last_visible_status', s.lastVisibleStatus ?? '?'),
|
||||
_KV('resume_state', s.resumeState),
|
||||
_KV('job_id', s.jobId),
|
||||
_KV('job_status', s.jobStatus),
|
||||
_KV(
|
||||
'drift_classes',
|
||||
s.driftClasses.isEmpty ? '-' : s.driftClasses.join(', '),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
final SessionRow session;
|
||||
const _Header({required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = session;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.accent, AppColors.accent2],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.accent.withValues(alpha: 0.35),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
s.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_Pill(text: s.status.toUpperCase()),
|
||||
_Pill(text: s.tmuxAlive ? 'TMUX ALIVE' : 'TMUX DEAD'),
|
||||
if (s.role != null) _Pill(text: s.role!.toUpperCase()),
|
||||
_Pill(text: 'SERVER: ${s.server}'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
final String text;
|
||||
const _Pill({required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final List<_KV> entries;
|
||||
const _Section({required this.title, required this.entries});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...entries.map((e) => _KVRow(entry: e)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KVRow extends StatelessWidget {
|
||||
final _KV entry;
|
||||
const _KVRow({required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Text(
|
||||
entry.label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
entry.value,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (entry.copyable)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(
|
||||
Icons.copy,
|
||||
size: 15,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
tooltip: 'Copy',
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: entry.value)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import 'package:data_table_2/data_table_2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class SessionTable extends StatelessWidget {
|
||||
final List<SessionRow> sessions;
|
||||
final String? selectedName;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
const SessionTable({
|
||||
super.key,
|
||||
required this.sessions,
|
||||
required this.selectedName,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: DataTable2(
|
||||
columnSpacing: 16,
|
||||
horizontalMargin: 16,
|
||||
minWidth: 900,
|
||||
headingRowColor: WidgetStateProperty.all(AppColors.surfaceAlt),
|
||||
headingTextStyle: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
dataRowHeight: 44,
|
||||
empty: const Center(
|
||||
child: Text(
|
||||
'(no sessions registered)',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
columns: const [
|
||||
DataColumn2(label: Text('NAME'), size: ColumnSize.L),
|
||||
DataColumn2(label: Text('SERVER'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('YAML'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('TMUX'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('CMD'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('RESUME'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('JOB_ID'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('JOB_STATUS'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('DRIFT'), size: ColumnSize.S),
|
||||
],
|
||||
rows: sessions.map((s) {
|
||||
final selected = s.name == selectedName;
|
||||
return DataRow2(
|
||||
selected: selected,
|
||||
color: selected
|
||||
? WidgetStateProperty.all(
|
||||
AppColors.accent.withValues(alpha: 0.16),
|
||||
)
|
||||
: null,
|
||||
onTap: () => onSelect(s.name),
|
||||
cells: [
|
||||
DataCell(
|
||||
Text(
|
||||
s.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
s.server,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
DataCell(_StatusChip(status: s.status)),
|
||||
DataCell(_TmuxChip(alive: s.tmuxAlive)),
|
||||
DataCell(
|
||||
Text(
|
||||
s.pane.cmd ?? '?',
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
s.resumeState,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
s.jobId,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
s.jobStatus,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
s.hasDrift
|
||||
? Text(
|
||||
s.driftClasses.join(','),
|
||||
style: const TextStyle(
|
||||
color: AppColors.warning,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'-',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final String status;
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color color;
|
||||
switch (status) {
|
||||
case 'running':
|
||||
color = AppColors.success;
|
||||
case 'stopped':
|
||||
case 'terminated':
|
||||
case 'archived':
|
||||
color = AppColors.textSecondary;
|
||||
default:
|
||||
color = AppColors.warning;
|
||||
}
|
||||
return Text(
|
||||
status,
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TmuxChip extends StatelessWidget {
|
||||
final bool alive;
|
||||
const _TmuxChip({required this.alive});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
alive ? 'alive' : 'dead',
|
||||
style: TextStyle(
|
||||
color: alive ? AppColors.success : AppColors.danger,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// D6: when `status.sh` polling fails, the last good snapshot is kept and
|
||||
/// this banner surfaces staleness instead of the dashboard silently
|
||||
/// blanking out or crashing.
|
||||
class StaleBanner extends StatelessWidget {
|
||||
final SessionsPoll poll;
|
||||
const StaleBanner({super.key, required this.poll});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!poll.stale) return const SizedBox.shrink();
|
||||
|
||||
final lastOk = poll.lastOkAt;
|
||||
final lastOkText = lastOk == null
|
||||
? '?'
|
||||
: '${lastOk.hour.toString().padLeft(2, '0')}:'
|
||||
'${lastOk.minute.toString().padLeft(2, '0')}:'
|
||||
'${lastOk.second.toString().padLeft(2, '0')}';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: AppColors.warning.withValues(alpha: 0.15),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: AppColors.warning,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'⚠ status snapshot stale (last ok: $lastOkText)',
|
||||
style: const TextStyle(
|
||||
color: AppColors.warning,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user