Commit 84fd69a4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: parallel runs made visible, real deploys, and twenty daily-use features

The two that hurt
- Two chats on one repo never ran together, and the app showed the waiting
  one as "working". The server now allows several runs per repo (Settings
  sets how many), and a queued run says what it is waiting for, everywhere
  it appears.
- Deploy did nothing. CapRover rejects the call the app was making, so the
  button reported success and shipped nothing; the fix builds the head of
  the app's branch. Apps with no repository say so instead of pretending.

Runs
 1. Several runs at once, per repo and overall, both adjustable in Settings.
 2. A "running now" sheet: everything working or waiting, with why, and a
    stop button for each. The Chats tab carries a live count.
 3. Type while a run is going and the message queues — it sends itself the
    moment that run ends.
 4. Fork a chat: same repo, last prompt ready to edit, both run in parallel.

Chats
 5. Dictation, with a language picker on long-press (an Arabic sentence in
    an English recogniser is nonsense).
 6. A file picker that inserts a repo path into the prompt.
 7. Search inside conversations — titles, prompts and answers — not just
    titles, with the matching line shown.
 8. Each finished run shows how long it took, and its exit code if it failed.
 9. Pin a chat (long-press) to keep it at the top.
10. Tapping a finished-run notification opens that chat, even from cold.

Git
11. A commit message written from the actual diff, then edited before use.
12. The branch chip switches branch, and creates one.
13. Commit history, each commit openable as its own patch.

Files
14. Content search across the repo, opening the file at the matching line.
15. Starred files and recents per project.
16. "Diff vs HEAD" on the open file.

Deploy
17. Deploy = build the latest commit on the branch (the fix above).
18. Roll back to a version CapRover still has the image for.
19. Restart, and scale instances (zero is a deliberate stop).
20. Box health in Settings: CPU, memory, disk, and what is running.

Version 1.5.0+15.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 90a71482
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Dictating a prompt instead of typing it on a phone. -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<application <application
android:label="Son of Anton" android:label="Son of Anton"
...@@ -50,5 +52,11 @@ ...@@ -50,5 +52,11 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<!-- Android 11+ hides other packages unless they are declared here, and
speech recognition lives in one of them: without this, dictation
reports "not available" on every modern phone. -->
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
</queries> </queries>
</manifest> </manifest>
...@@ -112,6 +112,9 @@ class ChatMessage { ...@@ -112,6 +112,9 @@ class ChatMessage {
this.routedAuto = false, this.routedAuto = false,
this.routedReason, this.routedReason,
this.wasPlan = false, this.wasPlan = false,
this.startedAt,
this.finishedAt,
this.queuedReason,
}); });
final String id; final String id;
...@@ -135,6 +138,21 @@ class ChatMessage { ...@@ -135,6 +138,21 @@ class ChatMessage {
/// nothing. The app offers to approve it. /// nothing. The app offers to approve it.
final bool wasPlan; final bool wasPlan;
/// When the run actually started and stopped — the difference is the only
/// honest "how long did that take".
final DateTime? startedAt;
final DateTime? finishedAt;
/// Set while a run is waiting its turn, saying what it waits for. Null once
/// it is running: waiting and working used to look identical.
final String? queuedReason;
bool get isQueued => (queuedReason ?? '').isNotEmpty;
Duration? get took => (startedAt == null || finishedAt == null)
? null
: finishedAt!.difference(startedAt!);
bool get isUser => role == 'user'; bool get isUser => role == 'user';
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage( factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
...@@ -151,6 +169,9 @@ class ChatMessage { ...@@ -151,6 +169,9 @@ class ChatMessage {
routedAuto: _b(j['routedAuto']), routedAuto: _b(j['routedAuto']),
routedReason: j['routedReason'] == null ? null : _s(j['routedReason']), routedReason: j['routedReason'] == null ? null : _s(j['routedReason']),
wasPlan: _b(j['wasPlan']), wasPlan: _b(j['wasPlan']),
startedAt: j['startedAt'] == null ? null : _dt(j['startedAt']),
finishedAt: j['finishedAt'] == null ? null : _dt(j['finishedAt']),
queuedReason: j['queuedReason'] == null ? null : _s(j['queuedReason']),
); );
ChatMessage copyWith({String? content, RunStatus? status}) => ChatMessage( ChatMessage copyWith({String? content, RunStatus? status}) => ChatMessage(
...@@ -167,6 +188,9 @@ class ChatMessage { ...@@ -167,6 +188,9 @@ class ChatMessage {
routedAuto: routedAuto, routedAuto: routedAuto,
routedReason: routedReason, routedReason: routedReason,
wasPlan: wasPlan, wasPlan: wasPlan,
startedAt: startedAt,
finishedAt: finishedAt,
queuedReason: queuedReason,
); );
} }
...@@ -449,6 +473,229 @@ class ZipInfo { ...@@ -449,6 +473,229 @@ class ZipInfo {
); );
} }
// --- what the box is doing ---------------------------------------------------
/// One run: working, or waiting to start and saying what for.
class RunInfo {
const RunInfo({
required this.id,
required this.project,
required this.prompt,
required this.queued,
this.reason = '',
this.elapsedSec = 0,
});
final String id;
final String project;
final String prompt;
final bool queued;
final String reason;
final int elapsedSec;
factory RunInfo.fromJson(Map<String, dynamic> j) => RunInfo(
id: _s(j['id']),
project: _s(j['project']),
prompt: _s(j['prompt']),
queued: _s(j['state']) == 'queued',
reason: _s(j['reason']),
elapsedSec: _i(j['elapsedSec']),
);
}
/// How much the server will run at once, and the ceilings it will not exceed.
class Limits {
const Limits({
required this.maxConcurrent,
required this.maxPerProject,
this.ceilingConcurrent = 8,
this.ceilingPerProject = 4,
});
final int maxConcurrent;
final int maxPerProject;
final int ceilingConcurrent;
final int ceilingPerProject;
factory Limits.fromJson(Map<String, dynamic> j) {
final c = j['ceiling'];
final ceiling = c is Map ? c.cast<String, dynamic>() : const <String, dynamic>{};
return Limits(
maxConcurrent: _i(j['maxConcurrent'], 4),
maxPerProject: _i(j['maxPerProject'], 2),
ceilingConcurrent: _i(ceiling['maxConcurrent'], 8),
ceilingPerProject: _i(ceiling['maxPerProject'], 4),
);
}
}
class RunSnapshot {
const RunSnapshot({this.running = const [], this.queued = const [], required this.limits});
final List<RunInfo> running;
final List<RunInfo> queued;
final Limits limits;
int get total => running.length + queued.length;
factory RunSnapshot.fromJson(Map<String, dynamic> j) => RunSnapshot(
running: _list(j['running'], RunInfo.fromJson),
queued: _list(j['queued'], RunInfo.fromJson),
limits: Limits.fromJson((j['limits'] as Map?)?.cast<String, dynamic>() ?? const {}),
);
}
class SystemHealth {
const SystemHealth({
required this.cpus,
required this.loadPerCore,
required this.memUsedPct,
required this.memTotalBytes,
required this.diskUsedPct,
required this.diskFreeBytes,
required this.uptimeSec,
required this.running,
required this.queued,
});
final int cpus;
final double loadPerCore;
final int memUsedPct;
final int memTotalBytes;
final int diskUsedPct;
final int diskFreeBytes;
final int uptimeSec;
final int running;
final int queued;
factory SystemHealth.fromJson(Map<String, dynamic> j) {
Map<String, dynamic> sub(String k) =>
(j[k] as Map?)?.cast<String, dynamic>() ?? const <String, dynamic>{};
final load = sub('load');
final mem = sub('memory');
final disk = sub('disk');
final runs = sub('runs');
return SystemHealth(
cpus: _i(j['cpus'], 1),
loadPerCore: double.tryParse('${load['perCore'] ?? 0}') ?? 0,
memUsedPct: _i(mem['usedPct']),
memTotalBytes: _i(mem['totalBytes']),
diskUsedPct: _i(disk['usedPct']),
diskFreeBytes: _i(disk['freeBytes']),
uptimeSec: _i(j['uptimeSec']),
running: _i(runs['running']),
queued: _i(runs['queued']),
);
}
}
// --- git ---------------------------------------------------------------------
class GitBranch {
const GitBranch({
required this.name,
required this.current,
this.subject = '',
this.upstream = '',
this.at,
});
final String name;
final bool current;
final String subject;
final String upstream;
final DateTime? at;
factory GitBranch.fromJson(Map<String, dynamic> j) => GitBranch(
name: _s(j['name']),
current: _b(j['current']),
subject: _s(j['subject']),
upstream: _s(j['upstream']),
at: (j['at'] == null || _s(j['at']).isEmpty) ? null : _dt(j['at']),
);
}
// --- search ------------------------------------------------------------------
/// One matching line from a content search.
class GrepHit {
const GrepHit({required this.path, required this.name, required this.line, required this.text});
final String path;
final String name;
final int line;
final String text;
factory GrepHit.fromJson(Map<String, dynamic> j) => GrepHit(
path: _s(j['path']),
name: _s(j['name']),
line: _i(j['line']),
text: _s(j['text']),
);
}
/// Where a chat matched: its title, something you asked, or something Claude said.
class ChatMatch {
const ChatMatch({required this.kind, required this.text, this.taskId = ''});
final String kind;
final String text;
final String taskId;
String get label => switch (kind) {
'you' => 'you',
'claude' => 'Claude',
_ => 'title',
};
factory ChatMatch.fromJson(Map<String, dynamic> j) => ChatMatch(
kind: _s(j['kind']),
text: _s(j['text']),
taskId: _s(j['taskId']),
);
}
class ChatHit {
const ChatHit({
required this.id,
required this.title,
required this.project,
required this.matches,
this.updatedAt,
});
final String id;
final String title;
final String project;
final List<ChatMatch> matches;
final DateTime? updatedAt;
factory ChatHit.fromJson(Map<String, dynamic> j) => ChatHit(
id: _s(j['id']),
title: _s(j['title'], 'Chat'),
project: _s(j['project']),
matches: _list(j['matches'], ChatMatch.fromJson),
updatedAt: j['updatedAt'] == null ? null : _dt(j['updatedAt']),
);
}
/// A version CapRover still holds an image for — what a rollback goes back to.
class CapVersion {
const CapVersion({required this.version, required this.image, this.gitHash = '', this.at});
final int version;
final String image;
final String gitHash;
final DateTime? at;
factory CapVersion.fromJson(Map<String, dynamic> j) => CapVersion(
version: _i(j['version']),
image: _s(j['image']),
gitHash: _s(j['gitHash']),
at: (j['at'] == null || _s(j['at']).isEmpty) ? null : _dt(j['at']),
);
}
class Commit { class Commit {
const Commit({required this.sha, required this.message, required this.author, required this.date}); const Commit({required this.sha, required this.message, required this.author, required this.date});
final String sha; final String sha;
...@@ -583,6 +830,8 @@ class CapApp { ...@@ -583,6 +830,8 @@ class CapApp {
this.domains = const [], this.domains = const [],
this.envKeys = const [], this.envKeys = const [],
this.volumes = const [], this.volumes = const [],
this.canDeployFromGit = false,
this.versions = const [],
}); });
final String name; final String name;
...@@ -604,6 +853,14 @@ class CapApp { ...@@ -604,6 +853,14 @@ class CapApp {
final List<CapEnvKey> envKeys; final List<CapEnvKey> envKeys;
final List<({String path, String volume})> volumes; final List<({String path, String volume})> volumes;
/// Whether "deploy the latest commit" is even possible: the app must have a
/// repository configured and a build webhook for it.
final bool canDeployFromGit;
/// Versions CapRover still holds images for — what a rollback can return to.
/// Only populated for a single app's detail, never the list.
final List<CapVersion> versions;
/// An app scaled to zero is intentionally stopped, which is different from /// An app scaled to zero is intentionally stopped, which is different from
/// broken — the UI should not shout about it. /// broken — the UI should not shout about it.
bool get stopped => instanceCount == 0; bool get stopped => instanceCount == 0;
...@@ -624,6 +881,8 @@ class CapApp { ...@@ -624,6 +881,8 @@ class CapApp {
forceSsl: _b(j['forceSsl']), forceSsl: _b(j['forceSsl']),
websocket: _b(j['websocket']), websocket: _b(j['websocket']),
domains: _strings(j['domains']), domains: _strings(j['domains']),
canDeployFromGit: _b(j['canDeployFromGit']),
versions: _list(j['versions'], CapVersion.fromJson),
envKeys: _list(j['envKeys'], CapEnvKey.fromJson), envKeys: _list(j['envKeys'], CapEnvKey.fromJson),
volumes: (j['volumes'] is List) volumes: (j['volumes'] is List)
? [ ? [
......
...@@ -191,6 +191,10 @@ class SonApi { ...@@ -191,6 +191,10 @@ class SonApi {
Future<void> stopChat(String id) async => _send('POST', '/api/chats/$id/stop'); Future<void> stopChat(String id) async => _send('POST', '/api/chats/$id/stop');
/// Cancels one run by task id — what the "running now" list acts on, where a
/// run is known by its task rather than by which chat it belongs to.
Future<void> cancelRun(String taskId) async => _send('DELETE', '/api/tasks/$taskId');
/// Returns the task id of the queued run, which is what the stream follows. /// Returns the task id of the queued run, which is what the stream follows.
Future<String> sendMessage( Future<String> sendMessage(
String chatId, { String chatId, {
...@@ -417,6 +421,57 @@ class SonApi { ...@@ -417,6 +421,57 @@ class SonApi {
Future<void> gitDiscard(String project, String file) async => Future<void> gitDiscard(String project, String file) async =>
_act(_send('POST', '/api/git/discard', body: {'project': project, 'path': file})); _act(_send('POST', '/api/git/discard', body: {'project': project, 'path': file}));
Future<List<GitBranch>> gitBranches(String project) async {
final j = _obj(await _get('/api/git/branches', {'repo': project}));
final l = j['branches'];
return l is List
? [
for (final e in l)
if (e is Map) GitBranch.fromJson(e.cast<String, dynamic>()),
]
: const [];
}
Future<void> gitCheckout(String project, String branch, {bool create = false}) async =>
_act(_send('POST', '/api/git/checkout',
body: {'repo': project, 'branch': branch, 'create': create}));
/// One commit's patch.
Future<String> gitShow(String project, String sha) async {
final j = _obj(await _get('/api/git/show', {'repo': project, 'sha': sha}));
return (j['diff'] ?? '').toString();
}
/// A commit message written from the real diff by the server's claude.
Future<String> gitCommitMessage(String project) async {
final j = await _act(_send('POST', '/api/git/commit-message', body: {'repo': project}));
return (j['message'] ?? '').toString();
}
/// Content search: what filename search cannot answer.
Future<List<GrepHit>> grep(
String project,
String query, {
String path = '.',
bool caseSensitive = false,
bool regex = false,
}) async {
final j = _obj(await _get('/api/files/grep', {
'project': project,
'q': query,
'path': path,
if (caseSensitive) 'caseSensitive': 'true',
if (regex) 'regex': 'true',
}));
final l = j['results'];
return l is List
? [
for (final e in l)
if (e is Map) GrepHit.fromJson(e.cast<String, dynamic>()),
]
: const [];
}
// --- caprover ------------------------------------------------------------- // --- caprover -------------------------------------------------------------
// //
// The phone never sees the CapRover password: it authenticates to DevPilot, // The phone never sees the CapRover password: it authenticates to DevPilot,
...@@ -431,10 +486,22 @@ class SonApi { ...@@ -431,10 +486,22 @@ class SonApi {
return CapApp.fromJson((j['app'] as Map).cast<String, dynamic>()); return CapApp.fromJson((j['app'] as Map).cast<String, dynamic>());
} }
/// Rebuild from the app's configured git repo, or deploy a specific image. /// Build and deploy the head of the app's branch, or deploy a specific image.
/// Wrapped in _act: "no repository configured" is an answer the user needs to
/// see, and the old call discarded it.
Future<void> capDeploy(String name, {String? image}) async => Future<void> capDeploy(String name, {String? image}) async =>
_send('POST', '/api/caprover/apps/$name/deploy', _act(_send('POST', '/api/caprover/apps/$name/deploy',
body: {if (image != null && image.isNotEmpty) 'image': image}); body: {if (image != null && image.isNotEmpty) 'image': image}));
/// Back to a version CapRover still holds the image for.
Future<void> capRollback(String name, int version) async =>
_act(_send('POST', '/api/caprover/apps/$name/rollback', body: {'version': version}));
Future<void> capRestart(String name) async =>
_act(_send('POST', '/api/caprover/apps/$name/restart'));
Future<void> capScale(String name, int instances) async =>
_act(_send('POST', '/api/caprover/apps/$name/scale', body: {'instances': instances}));
Future<void> capSetRepo( Future<void> capSetRepo(
String name, { String name, {
...@@ -468,6 +535,36 @@ class SonApi { ...@@ -468,6 +535,36 @@ class SonApi {
: const []; : const [];
} }
// --- what the box is doing --------------------------------------------------
/// Everything running or waiting, and the limits that decide which is which.
Future<RunSnapshot> runs() async => RunSnapshot.fromJson(_obj(await _get('/api/runs')));
Future<Limits> limits() async => Limits.fromJson(_obj(await _get('/api/limits')));
Future<Limits> setLimits({int? maxConcurrent, int? maxPerProject}) async =>
Limits.fromJson(await _act(_send('PATCH', '/api/limits', body: {
if (maxConcurrent != null) 'maxConcurrent': maxConcurrent,
if (maxPerProject != null) 'maxPerProject': maxPerProject,
})));
Future<SystemHealth> system() async => SystemHealth.fromJson(_obj(await _get('/api/system')));
/// Searches inside conversations — titles, prompts and answers.
Future<List<ChatHit>> searchChats(String query, {String? project}) async {
final j = _obj(await _get('/api/chats/search', {
'q': query,
if (project != null && project.isNotEmpty) 'project': project,
}));
final l = j['results'];
return l is List
? [
for (final e in l)
if (e is Map) ChatHit.fromJson(e.cast<String, dynamic>()),
]
: const [];
}
// --- usage ---------------------------------------------------------------- // --- usage ----------------------------------------------------------------
/// [force] bypasses the server's 60s cache — the Settings refresh button. /// [force] bypasses the server's 60s cache — the Settings refresh button.
......
...@@ -21,6 +21,34 @@ abstract final class Notifications { ...@@ -21,6 +21,34 @@ abstract final class Notifications {
styleInformation: BigTextStyleInformation(''), styleInformation: BigTextStyleInformation(''),
); );
/// What to do when one of these notifications is tapped. Set by the shell
/// once it can navigate.
static void Function(String chatId)? onOpenChat;
/// A tap that arrived before anything could handle it — the app was not
/// running when the notification was pressed.
static String? _pending;
static void _tapped(NotificationResponse r) {
final id = r.payload ?? '';
if (id.isEmpty) return;
final open = onOpenChat;
if (open == null) {
_pending = id;
return;
}
open(id);
}
/// Delivers a tap that happened while the app was closed. Called once the UI
/// exists; without it, a cold start from a notification lands on the tab you
/// last had open instead of the run you were told about.
static void flushPending() {
final id = _pending;
_pending = null;
if (id != null && id.isNotEmpty) onOpenChat?.call(id);
}
static Future<void> init() async { static Future<void> init() async {
if (_ready) return; if (_ready) return;
try { try {
...@@ -28,7 +56,13 @@ abstract final class Notifications { ...@@ -28,7 +56,13 @@ abstract final class Notifications {
const InitializationSettings( const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'), android: AndroidInitializationSettings('@mipmap/ic_launcher'),
), ),
onDidReceiveNotificationResponse: _tapped,
); );
final launch = await _plugin.getNotificationAppLaunchDetails();
if (launch?.didNotificationLaunchApp ?? false) {
final id = launch?.notificationResponse?.payload ?? '';
if (id.isNotEmpty) _pending = id;
}
await _plugin await _plugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>() .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission(); ?.requestNotificationsPermission();
...@@ -43,14 +77,19 @@ abstract final class Notifications { ...@@ -43,14 +77,19 @@ abstract final class Notifications {
required String chatTitle, required String chatTitle,
required String project, required String project,
required bool ok, required bool ok,
String chatId = '',
}) async { }) async {
if (!_ready) return; if (!_ready) return;
try { try {
await _plugin.show( await _plugin.show(
chatTitle.hashCode & 0x7fffffff, // Keyed by chat, so a second run in the same chat replaces its notice
// rather than stacking another one.
(chatId.isEmpty ? chatTitle : chatId).hashCode & 0x7fffffff,
ok ? 'Finished · $project' : 'Failed · $project', ok ? 'Finished · $project' : 'Failed · $project',
chatTitle, chatTitle,
const NotificationDetails(android: _channel), const NotificationDetails(android: _channel),
// Carries which chat to open when it is tapped.
payload: chatId,
); );
} catch (e) { } catch (e) {
debugPrint('notify failed: $e'); debugPrint('notify failed: $e');
......
...@@ -5,7 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; ...@@ -5,7 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart';
/// Shown in Settings. Kept beside pubspec's `version:` by hand — reading it at /// Shown in Settings. Kept beside pubspec's `version:` by hand — reading it at
/// runtime would need package_info_plus for one string. /// runtime would need package_info_plus for one string.
const kAppVersion = '1.4.0'; const kAppVersion = '1.5.0';
/// On-device conveniences: things that make the app nicer to come back to and /// On-device conveniences: things that make the app nicer to come back to and
/// that the server has no reason to know. /// that the server has no reason to know.
...@@ -65,6 +65,50 @@ class Prefs { ...@@ -65,6 +65,50 @@ class Prefs {
static Future<void> setFileSort(String v) async => (await _p).setString('file_sort', v); static Future<void> setFileSort(String v) async => (await _p).setString('file_sort', v);
// --- chats ------------------------------------------------------------------
/// Chats kept at the top of the list. Ids, so a rename does not lose the pin.
static Future<Set<String>> pinnedChats() async =>
((await _p).getStringList('pinned_chats') ?? const []).toSet();
static Future<Set<String>> togglePinnedChat(String id) async {
final p = await _p;
final next = ((p.getStringList('pinned_chats') ?? const <String>[]).toSet());
if (!next.remove(id)) next.add(id);
await p.setStringList('pinned_chats', next.toList());
return next;
}
/// The language dictation listens for. Empty means the phone's own default,
/// which is right until you speak Arabic to an English phone.
static Future<String> dictationLocale() async => (await _p).getString('dictation_locale') ?? '';
static Future<void> setDictationLocale(String id) async =>
(await _p).setString('dictation_locale', id);
// --- files ------------------------------------------------------------------
static Future<List<String>> starredFiles(String project) async =>
(await _p).getStringList('starred.$project') ?? const [];
static Future<List<String>> toggleStarredFile(String project, String path) async {
final p = await _p;
final list = [...(p.getStringList('starred.$project') ?? const <String>[])];
if (!list.remove(path)) list.insert(0, path);
await p.setStringList('starred.$project', list.take(100).toList());
return list;
}
static Future<List<String>> recentFiles(String project) async =>
(await _p).getStringList('recent.$project') ?? const [];
/// Most recent first, no duplicates, capped — a trail, not a history.
static Future<void> pushRecentFile(String project, String path) async {
final p = await _p;
final list = [...(p.getStringList('recent.$project') ?? const <String>[])]..remove(path);
await p.setStringList('recent.$project', [path, ...list].take(30).toList());
}
// --- display ---------------------------------------------------------------- // --- display ----------------------------------------------------------------
/// Keep the screen on while a run you are watching is live. On by default: /// Keep the screen on while a run you are watching is live. On by default:
......
...@@ -119,6 +119,28 @@ final gitStatusProvider = FutureProvider<List<RepoStatus>>((ref) async { ...@@ -119,6 +119,28 @@ final gitStatusProvider = FutureProvider<List<RepoStatus>>((ref) async {
return ref.watch(apiProvider).gitStatusAll(); return ref.watch(apiProvider).gitStatusAll();
}); });
/// CPU, memory and disk of the box — "is the server struggling?" without ssh.
final systemProvider =
FutureProvider.autoDispose<SystemHealth>((ref) => ref.watch(apiProvider).system());
/// How much the server will run at once. Changeable from Settings.
final limitsProvider = FutureProvider.autoDispose<Limits>((ref) => ref.watch(apiProvider).limits());
/// What the box is working on, polled while something is watching. This is what
/// lets the app say "queued behind another run in clubphp" instead of showing a
/// spinner that means nothing.
final runsProvider = StreamProvider.autoDispose<RunSnapshot>((ref) async* {
final api = ref.watch(apiProvider);
while (true) {
try {
yield await api.runs();
} catch (_) {
// Keep the last good snapshot: a dropped poll is not news.
}
await Future<void>.delayed(const Duration(seconds: 5));
}
});
// --- chats ------------------------------------------------------------------ // --- chats ------------------------------------------------------------------
/// The chat list, polled while anything is running so the tab reflects work /// The chat list, polled while anything is running so the tab reflects work
......
...@@ -52,6 +52,7 @@ class _Watcher extends TaskHandler { ...@@ -52,6 +52,7 @@ class _Watcher extends TaskHandler {
for (final id in finished) { for (final id in finished) {
final title = _titles[id] ?? 'Task finished'; final title = _titles[id] ?? 'Task finished';
await Notifications.runFinished( await Notifications.runFinished(
chatId: id,
chatTitle: title, chatTitle: title,
project: _projects[id] ?? '', project: _projects[id] ?? '',
ok: _ok[id] ?? true, ok: _ok[id] ?? true,
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
/// Everything the server is doing, and everything waiting to start.
///
/// This exists because a queued run and a working run looked identical from the
/// phone: two chats on one repo appeared to hang. Now the queue is visible,
/// says what it is waiting for, and can be cancelled from here.
void showRunsSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.92,
builder: (_, ctrl) => _RunsView(controller: ctrl),
),
);
}
class _RunsView extends ConsumerWidget {
const _RunsView({required this.controller});
final ScrollController controller;
@override
Widget build(BuildContext context, WidgetRef ref) {
final snap = ref.watch(runsProvider);
final data = snap.valueOrNull;
final running = data?.running ?? const <RunInfo>[];
final queued = data?.queued ?? const <RunInfo>[];
final limits = data?.limits;
return Column(
children: [
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.bolt, size: 18, color: Ember.flame),
const SizedBox(width: 8),
Text(
running.isEmpty && queued.isEmpty
? 'Nothing running'
: '${running.length} running · ${queued.length} waiting',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
],
),
if (limits != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Up to ${limits.maxConcurrent} at once, ${limits.maxPerProject} per repo · change in Settings',
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
),
const SizedBox(height: 10),
const Divider(height: 1),
Expanded(
child: snap.hasError && data == null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${snap.error}',
textAlign: TextAlign.center,
style: const TextStyle(color: Ember.textDim)),
),
)
: (running.isEmpty && queued.isEmpty)
? const Center(
child: Padding(
padding: EdgeInsets.all(28),
child: Text(
'The box is idle.\nStart a chat and it appears here.',
textAlign: TextAlign.center,
style: TextStyle(color: Ember.textDim, height: 1.5),
),
),
)
: ListView(
controller: controller,
padding: const EdgeInsets.only(bottom: 24),
children: [
for (final r in running) _RunRow(run: r),
if (queued.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.fromLTRB(16, 14, 16, 6),
child: Text('WAITING',
style: TextStyle(
fontSize: 10.5,
letterSpacing: 0.8,
color: Ember.amber,
fontWeight: FontWeight.w600)),
),
for (final r in queued) _RunRow(run: r),
],
],
),
),
],
);
}
}
class _RunRow extends ConsumerWidget {
const _RunRow({required this.run});
final RunInfo run;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
dense: true,
leading: run.queued
? const Icon(Icons.schedule, size: 18, color: Ember.amber)
: const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Ember.flame),
),
title: Text(
run.prompt.isEmpty ? '(no prompt)' : run.prompt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
run.queued
? (run.reason.isEmpty ? 'waiting' : run.reason)
: '${run.project} · ${_elapsed(run.elapsedSec)}',
style: TextStyle(fontSize: 11, color: run.queued ? Ember.amber : Ember.textFaint),
),
trailing: IconButton(
tooltip: run.queued ? 'Drop from the queue' : 'Stop this run',
icon: const Icon(Icons.stop_circle_outlined, size: 20, color: Ember.bad),
onPressed: () async {
try {
await ref.read(apiProvider).cancelRun(run.id);
ref.invalidate(runsProvider);
} on ApiException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
},
),
);
}
}
String _elapsed(int seconds) {
if (seconds < 60) return '${seconds}s';
final m = seconds ~/ 60;
return m < 60 ? '${m}m ${seconds % 60}s' : '${m ~/ 60}h ${m % 60}m';
}
...@@ -11,6 +11,7 @@ import '../../core/api/son_api.dart'; ...@@ -11,6 +11,7 @@ import '../../core/api/son_api.dart';
import '../../core/prefs.dart'; import '../../core/prefs.dart';
import '../../core/providers.dart'; import '../../core/providers.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../../widgets/diff_text.dart';
import 'file_actions.dart'; import 'file_actions.dart';
import 'mermaid_view.dart'; import 'mermaid_view.dart';
import 'pdf_flow.dart'; import 'pdf_flow.dart';
...@@ -23,12 +24,17 @@ class EditorScreen extends ConsumerStatefulWidget { ...@@ -23,12 +24,17 @@ class EditorScreen extends ConsumerStatefulWidget {
required this.project, required this.project,
required this.path, required this.path,
required this.name, required this.name,
this.initialLine,
}); });
final String project; final String project;
final String path; final String path;
final String name; final String name;
/// Where a content-search match was: the file opens with the caret there
/// rather than at the top of a two-thousand-line file.
final int? initialLine;
@override @override
ConsumerState<EditorScreen> createState() => _EditorScreenState(); ConsumerState<EditorScreen> createState() => _EditorScreenState();
} }
...@@ -73,6 +79,15 @@ class _EditorScreenState extends ConsumerState<EditorScreen> { ...@@ -73,6 +79,15 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
_loading = false; _loading = false;
if (!f.isBinary) _code.text = f.content; if (!f.isBinary) _code.text = f.content;
}); });
final line = widget.initialLine;
if (line != null && !f.isBinary) {
try {
final at = (line - 1).clamp(0, _code.codeLines.length - 1);
_code.selection = CodeLineSelection.collapsed(index: at, offset: 0);
} catch (_) {
// Best effort: landing at the top still shows the file.
}
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
...@@ -128,12 +143,61 @@ class _EditorScreenState extends ConsumerState<EditorScreen> { ...@@ -128,12 +143,61 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
// The text on screen, unsaved edits included. // The text on screen, unsaved edits included.
PdfFlow.forFile(context, api, PdfFlow.forFile(context, api,
project: widget.project, path: widget.path, name: widget.name, source: _code.text); project: widget.project, path: widget.path, name: widget.name, source: _code.text);
case 'diff':
_showDiff();
case 'path': case 'path':
Clipboard.setData(ClipboardData(text: widget.path)); Clipboard.setData(ClipboardData(text: widget.path));
toast(context, 'Copied ${widget.path}'); toast(context, 'Copied ${widget.path}');
} }
} }
/// What changed in this file since the last commit — the question worth
/// asking right after an agent edits it.
Future<void> _showDiff() async {
try {
final diff = await ref.read(apiProvider).gitDiff(widget.project, widget.path);
if (!mounted) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.8,
builder: (_, ctrl) => Column(
children: [
const SizedBox(height: 6),
ListTile(
dense: true,
title: Text(widget.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(
diff.trim().isEmpty ? 'unchanged since the last commit' : diffStat(diff),
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
),
const Divider(height: 1),
Expanded(
child: diff.trim().isEmpty
? const Center(
child: Text('No changes', style: TextStyle(color: Ember.textDim)))
: SingleChildScrollView(
controller: ctrl,
padding: const EdgeInsets.all(14),
child: DiffText(diff: diff),
),
),
],
),
),
);
} on ApiException catch (e) {
if (mounted) toast(context, e.message, bad: true);
}
}
Future<bool> _confirmLeave() async { Future<bool> _confirmLeave() async {
if (!_dirty) return true; if (!_dirty) return true;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
...@@ -214,6 +278,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> { ...@@ -214,6 +278,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
const PopupMenuItem(value: 'share', child: Text('Share')), const PopupMenuItem(value: 'share', child: Text('Share')),
if (f != null && !f.isBinary) if (f != null && !f.isBinary)
const PopupMenuItem(value: 'pdf', child: Text('Export as PDF')), const PopupMenuItem(value: 'pdf', child: Text('Export as PDF')),
const PopupMenuItem(value: 'diff', child: Text('Diff vs HEAD')),
const PopupMenuItem(value: 'path', child: Text('Copy path')), const PopupMenuItem(value: 'path', child: Text('Copy path')),
], ],
), ),
......
This diff is collapsed.
This diff is collapsed.
...@@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; ...@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/models.dart'; import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/lock.dart'; import '../../core/lock.dart';
import '../../core/prefs.dart'; import '../../core/prefs.dart';
import '../../core/providers.dart'; import '../../core/providers.dart';
...@@ -42,6 +43,20 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ...@@ -42,6 +43,20 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
}); });
} }
Future<void> _setLimits({int? maxConcurrent, int? maxPerProject}) async {
try {
await ref
.read(apiProvider)
.setLimits(maxConcurrent: maxConcurrent, maxPerProject: maxPerProject);
ref.invalidate(limitsProvider);
ref.invalidate(runsProvider);
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = ref.watch(sessionProvider); final session = ref.watch(sessionProvider);
...@@ -105,6 +120,78 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ...@@ -105,6 +120,78 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const _SectionLabel('Server'),
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: ref.watch(systemProvider).when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(10),
child: SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
),
),
error: (e, _) => Text('Unavailable — $e',
style: const TextStyle(color: Ember.textDim, fontSize: 12)),
data: (s) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Meter(label: 'CPU load', pct: (s.loadPerCore * 100).round(),
detail: '${s.loadPerCore.toStringAsFixed(2)} per core · ${s.cpus} CPUs'),
const SizedBox(height: 12),
_Meter(label: 'Memory', pct: s.memUsedPct, detail: _gb(s.memTotalBytes)),
const SizedBox(height: 12),
_Meter(label: 'Disk', pct: s.diskUsedPct, detail: '${_gb(s.diskFreeBytes)} free'),
const SizedBox(height: 10),
Text(
'${s.running} running · ${s.queued} waiting · up ${_uptime(s.uptimeSec)}',
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
],
),
),
),
),
const SizedBox(height: 20),
const _SectionLabel('How much runs at once'),
Card(
child: ref.watch(limitsProvider).when(
loading: () => const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('Unavailable — $e',
style: const TextStyle(color: Ember.textDim, fontSize: 12)),
),
data: (l) => Column(
children: [
_LimitSlider(
label: 'Runs at once',
value: l.maxConcurrent,
max: l.ceilingConcurrent,
note: 'Across every repo. More means more of the Claude quota at once.',
onChanged: (v) => _setLimits(maxConcurrent: v),
),
const Divider(height: 1),
_LimitSlider(
label: 'Runs per repo',
value: l.maxPerProject,
max: l.ceilingPerProject,
note: 'Two chats on one repo work at the same time. They share one '
'checkout, so they can still tread on each other.',
onChanged: (v) => _setLimits(maxPerProject: v),
),
],
),
),
),
const SizedBox(height: 20),
const _SectionLabel('Display'), const _SectionLabel('Display'),
Card( Card(
child: Column( child: Column(
...@@ -324,3 +411,111 @@ class _UsageBar extends StatelessWidget { ...@@ -324,3 +411,111 @@ class _UsageBar extends StatelessWidget {
return 'in ${d.inMinutes}m'; return 'in ${d.inMinutes}m';
} }
} }
String _gb(int bytes) => '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
String _uptime(int seconds) {
final d = seconds ~/ 86400;
final h = (seconds % 86400) ~/ 3600;
return d > 0 ? '${d}d ${h}h' : '${h}h';
}
/// One bar of "how much of this is in use". Colour tracks headroom, not brand.
class _Meter extends StatelessWidget {
const _Meter({required this.label, required this.pct, required this.detail});
final String label;
final int pct;
final String detail;
@override
Widget build(BuildContext context) {
final v = pct.clamp(0, 100);
final color = v >= 90 ? Ember.bad : (v >= 70 ? Ember.amber : Ember.ok);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(label, style: const TextStyle(fontSize: 13)),
const SizedBox(width: 8),
Text(detail, style: const TextStyle(fontSize: 10.5, color: Ember.textFaint)),
const Spacer(),
Text('$v%',
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700, color: color)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: v / 100,
minHeight: 6,
backgroundColor: Ember.surfaceHi,
valueColor: AlwaysStoppedAnimation(color),
),
),
],
);
}
}
/// A whole-number limit. Committed on release, not on every pixel of the drag:
/// each change is a server call.
class _LimitSlider extends StatefulWidget {
const _LimitSlider({
required this.label,
required this.value,
required this.max,
required this.note,
required this.onChanged,
});
final String label;
final int value;
final int max;
final String note;
final ValueChanged<int> onChanged;
@override
State<_LimitSlider> createState() => _LimitSliderState();
}
class _LimitSliderState extends State<_LimitSlider> {
double? _dragging;
@override
Widget build(BuildContext context) {
final shown = (_dragging ?? widget.value.toDouble()).clamp(1.0, widget.max.toDouble()).toDouble();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(widget.label, style: const TextStyle(fontSize: 14)),
const Spacer(),
Text('${shown.round()}',
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700, color: Ember.flame)),
],
),
Slider(
value: shown,
min: 1,
max: widget.max.toDouble(),
divisions: widget.max - 1,
activeColor: Ember.flame,
label: '${shown.round()}',
onChanged: (v) => setState(() => _dragging = v),
onChangeEnd: (v) {
setState(() => _dragging = null);
widget.onChanged(v.round());
},
),
Text(widget.note, style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
],
),
);
}
}
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/notifications.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../caprover/caprover_screen.dart'; import '../caprover/caprover_screen.dart';
import '../chats/chat_screen.dart';
import '../chats/chats_screen.dart'; import '../chats/chats_screen.dart';
import '../files/files_screen.dart'; import '../files/files_screen.dart';
import '../git/git_screen.dart'; import '../git/git_screen.dart';
...@@ -22,6 +24,28 @@ class HomeShell extends ConsumerStatefulWidget { ...@@ -22,6 +24,28 @@ class HomeShell extends ConsumerStatefulWidget {
class _HomeShellState extends ConsumerState<HomeShell> { class _HomeShellState extends ConsumerState<HomeShell> {
int _index = 1; // open on Chats: it is what the app is for int _index = 1; // open on Chats: it is what the app is for
@override
void initState() {
super.initState();
// Tapping "your run finished" should land in that conversation, not merely
// open the app on whatever tab was last used.
Notifications.onOpenChat = _openChat;
// A tap that launched the app from cold happened before this existed.
WidgetsBinding.instance.addPostFrameCallback((_) => Notifications.flushPending());
}
@override
void dispose() {
if (Notifications.onOpenChat == _openChat) Notifications.onOpenChat = null;
super.dispose();
}
void _openChat(String chatId) {
if (!mounted || chatId.isEmpty) return;
setState(() => _index = 1);
Navigator.of(context).push(MaterialPageRoute(builder: (_) => ChatScreen(chatId: chatId)));
}
static const _tabs = <Widget>[ static const _tabs = <Widget>[
VsCodeScreen(), VsCodeScreen(),
ChatsScreen(), ChatsScreen(),
......
import 'package:flutter/material.dart';
import '../core/theme.dart';
/// A patch, coloured the way every git tool colours one: additions green,
/// removals red, hunk headers blue. Selectable, because the reason to look at a
/// diff on a phone is usually to copy a line out of it.
class DiffText extends StatelessWidget {
const DiffText({super.key, required this.diff});
final String diff;
@override
Widget build(BuildContext context) => SelectableText.rich(
TextSpan(children: [
for (final line in diff.split('\n'))
TextSpan(
text: '$line\n',
style: TextStyle(
fontSize: 11.5,
height: 1.4,
fontFamilyFallback: const [...Ember.mono],
color: line.startsWith('+') && !line.startsWith('+++')
? Ember.ok
: line.startsWith('-') && !line.startsWith('---')
? Ember.bad
: line.startsWith('@@')
? Ember.info
: Ember.textDim,
),
),
]),
);
}
/// "+12 −3" for a patch — the shape of a change before reading it.
String diffStat(String diff) {
var add = 0;
var del = 0;
for (final l in diff.split('\n')) {
if (l.startsWith('+') && !l.startsWith('+++')) {
add++;
} else if (l.startsWith('-') && !l.startsWith('---')) {
del++;
}
}
return '+$add −$del';
}
...@@ -949,6 +949,30 @@ packages: ...@@ -949,6 +949,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" version: "1.10.2"
speech_to_text:
dependency: "direct main"
description:
name: speech_to_text
sha256: f2fabd017b5cb07f4e06d496b2d4b957e39534bc848f92b1e6acac80a10f4967
url: "https://pub.dev"
source: hosted
version: "7.5.0"
speech_to_text_platform_interface:
dependency: transitive
description:
name: speech_to_text_platform_interface
sha256: "7e447356be3074c471223c9d179ef3f822475350b67ca923ec71ab3bf2ede214"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
speech_to_text_windows:
dependency: transitive
description:
name: speech_to_text_windows
sha256: "2d1d10565b23262386b453b33656299608dc7a66784453735d6c1318f13f44d7"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
......
name: son_of_anton name: son_of_anton
description: Drive Claude Code against your GitLab repos from your phone. description: Drive Claude Code against your GitLab repos from your phone.
publish_to: 'none' publish_to: 'none'
version: 1.4.0+14 version: 1.5.0+15
environment: environment:
sdk: ^3.8.0 sdk: ^3.8.0
...@@ -57,6 +57,7 @@ dependencies: ...@@ -57,6 +57,7 @@ dependencies:
collection: ^1.18.0 collection: ^1.18.0
printing: ^5.15.0 printing: ^5.15.0
wakelock_plus: ^1.5.2 wakelock_plus: ^1.5.2
speech_to_text: ^7.5.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment