feat(ui): complete M1 Milestone - read-only Dashboard and Detail Pane with status.sh integration

This commit is contained in:
2026-07-16 21:09:31 +09:00
parent 7d22774d76
commit 2eb85866b3
22 changed files with 2014 additions and 399 deletions
@@ -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)),
),
),
],
),
);
}
}
@@ -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,
),
),
],
),
);
}
}