Commit c50315c4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: CapRover tab, prompt-aware routing, and chat quality-of-life

Three things asked for, plus the plumbing they needed.

OPENING A CHAT LANDS AT THE BOTTOM. It used to open at the top of the transcript
and leave you scrolling to reach the newest message. It jumps rather than
animates, so a long history does not scroll past before settling.

MODEL AND EFFORT NOW STICK. The composer's pick was held only in widget state,
so it was lost on the next rebuild and every following message silently reverted
to Auto. A choice now writes through to the chat itself and survives leaving the
screen and restarting the app.

AUTO ACTUALLY CHOOSES, AND SAYS WHY. Auto used to mean "send no flag". It now
routes per message from the prompt, and every message shows the model, the
effort, and the reason — "opus · xhigh · auto: complex task". That visibility is
the point: routing nobody can see is routing nobody can correct, and the
thresholds are meant to be tuned against real use.

  What is this repo about              -> fable · low     (lookup)
  add a null check to line 42          -> sonnet · medium (code change)
  Refactor billing, migrate the schema -> opus · xhigh    (complex task)

Bias is toward the cheaper option: under-serving a hard prompt costs one visible
retry, over-serving every trivial one quietly burns the subscription all day.

CAPROVER TAB. See what is deployed, what it deploys from, redeploy, repoint an
app at a different repo/branch, and read the build log — without leaving the
phone. Deliberately scoped:

- The phone never holds the CapRover password. It authenticates to DevPilot,
  which holds that credential server-side, so a lost device is a token change
  here rather than a password rotation across everything.
- Env var VALUES never leave the server — the app shows which keys exist, not
  what they hold. Those are database passwords and API keys for 54 apps.
- Creating and deleting apps are not exposed at all. Irreversible actions
  against live client deployments belong at a desk, not one mis-tap away.
- Redeploy is confirmed, because it restarts a live service and you are by
  definition not at a desk when you press it.
- Changing the deploy source reads the whole app definition and sends it back
  with only the repo altered: CapRover's update endpoint replaces the object, so
  a partial body silently drops volumes and env vars.

Verified against the real CapRover: 54 apps enumerated with versions, images and
git sources.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 66515ead
...@@ -109,6 +109,8 @@ class ChatMessage { ...@@ -109,6 +109,8 @@ class ChatMessage {
this.model, this.model,
this.effort, this.effort,
this.exitCode, this.exitCode,
this.routedAuto = false,
this.routedReason,
}); });
final String id; final String id;
...@@ -122,6 +124,12 @@ class ChatMessage { ...@@ -122,6 +124,12 @@ class ChatMessage {
final String? effort; final String? effort;
final int? exitCode; final int? exitCode;
/// Whether the server's router chose the model/effort, and the reason it
/// gave. Shown beside a running message so the routing is visible rather than
/// happening silently.
final bool routedAuto;
final String? routedReason;
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(
...@@ -135,6 +143,8 @@ class ChatMessage { ...@@ -135,6 +143,8 @@ class ChatMessage {
model: j['model'] == null ? null : _s(j['model']), model: j['model'] == null ? null : _s(j['model']),
effort: j['effort'] == null ? null : _s(j['effort']), effort: j['effort'] == null ? null : _s(j['effort']),
exitCode: _iOrNull(j['exitCode']), exitCode: _iOrNull(j['exitCode']),
routedAuto: _b(j['routedAuto']),
routedReason: j['routedReason'] == null ? null : _s(j['routedReason']),
); );
ChatMessage copyWith({String? content, RunStatus? status}) => ChatMessage( ChatMessage copyWith({String? content, RunStatus? status}) => ChatMessage(
...@@ -148,6 +158,8 @@ class ChatMessage { ...@@ -148,6 +158,8 @@ class ChatMessage {
model: model, model: model,
effort: effort, effort: effort,
exitCode: exitCode, exitCode: exitCode,
routedAuto: routedAuto,
routedReason: routedReason,
); );
} }
...@@ -418,3 +430,113 @@ class Usage { ...@@ -418,3 +430,113 @@ class Usage {
); );
} }
} }
// --- caprover ---------------------------------------------------------------
class CapEnvKey {
const CapEnvKey({required this.key, required this.hasValue});
final String key;
final bool hasValue;
factory CapEnvKey.fromJson(Map<String, dynamic> j) =>
CapEnvKey(key: _s(j['key']), hasValue: _b(j['hasValue']));
}
class CapRepo {
const CapRepo({this.url = '', this.branch = '', this.user = ''});
final String url;
final String branch;
final String user;
bool get isSet => url.isNotEmpty;
factory CapRepo.fromJson(Map<String, dynamic> j) => CapRepo(
url: _s(j['url']),
branch: _s(j['branch']),
user: _s(j['user']),
);
}
class CapApp {
const CapApp({
required this.name,
required this.deployedVersion,
required this.image,
required this.building,
required this.instanceCount,
required this.repo,
this.hasPersistentData = false,
this.ssl = false,
this.forceSsl = false,
this.websocket = false,
this.domains = const [],
this.envKeys = const [],
this.volumes = const [],
});
final String name;
final int deployedVersion;
final String image;
final bool building;
final int instanceCount;
final CapRepo repo;
final bool hasPersistentData;
final bool ssl;
final bool forceSsl;
final bool websocket;
final List<String> domains;
final List<CapEnvKey> envKeys;
final List<({String path, String volume})> volumes;
/// An app scaled to zero is intentionally stopped, which is different from
/// broken — the UI should not shout about it.
bool get stopped => instanceCount == 0;
factory CapApp.fromJson(Map<String, dynamic> j) => CapApp(
name: _s(j['name']),
deployedVersion: _i(j['deployedVersion']),
image: _s(j['image']),
building: _b(j['building']),
instanceCount: _i(j['instanceCount']),
repo: j['repo'] is Map
? CapRepo.fromJson((j['repo'] as Map).cast<String, dynamic>())
: const CapRepo(),
hasPersistentData: _b(j['hasPersistentData']),
ssl: _b(j['ssl']),
forceSsl: _b(j['forceSsl']),
websocket: _b(j['websocket']),
domains: _strings(j['domains']),
envKeys: _list(j['envKeys'], CapEnvKey.fromJson),
volumes: (j['volumes'] is List)
? [
for (final v in j['volumes'] as List)
if (v is Map)
(
path: _s(v['path']),
volume: _s(v['volume']),
),
]
: const [],
);
}
class CapOverview {
const CapOverview({
required this.configured,
required this.rootDomain,
required this.apps,
this.error = '',
});
final bool configured;
final String rootDomain;
final List<CapApp> apps;
final String error;
factory CapOverview.fromJson(Map<String, dynamic> j) => CapOverview(
configured: _b(j['configured']),
rootDomain: _s(j['rootDomain']),
apps: _list(j['apps'], CapApp.fromJson),
error: _s(j['error']),
);
}
...@@ -290,6 +290,45 @@ class SonApi { ...@@ -290,6 +290,45 @@ class SonApi {
Future<void> gitDiscard(String project, String file) async => Future<void> gitDiscard(String project, String file) async =>
_send('POST', '/api/git/discard', body: {'project': project, 'path': file}); _send('POST', '/api/git/discard', body: {'project': project, 'path': file});
// --- caprover -------------------------------------------------------------
//
// The phone never sees the CapRover password: it authenticates to DevPilot,
// which holds that credential server-side. Env var VALUES are never returned
// either — only which keys exist.
Future<CapOverview> capApps() async =>
CapOverview.fromJson(_obj(await _get('/api/caprover/apps')));
Future<CapApp> capApp(String name) async {
final j = _obj(await _get('/api/caprover/apps/\$name'));
return CapApp.fromJson((j['app'] as Map).cast<String, dynamic>());
}
/// Rebuild from the app's configured git repo, or deploy a specific image.
Future<void> capDeploy(String name, {String? image}) async =>
_send('POST', '/api/caprover/apps/\$name/deploy',
body: {if (image != null && image.isNotEmpty) 'image': image});
Future<void> capSetRepo(
String name, {
required String url,
String? branch,
String? user,
String? password,
}) async =>
_send('POST', '/api/caprover/apps/\$name/repo', body: {
'url': url,
if (branch != null) 'branch': branch,
if (user != null) 'user': user,
// Omitted means "keep the stored one" — the client never receives it.
if (password != null && password.isNotEmpty) 'password': password,
});
Future<String> capLogs(String name) async {
final j = _obj(await _get('/api/caprover/apps/\$name/logs'));
return (j['logs'] ?? '').toString();
}
// --- usage ---------------------------------------------------------------- // --- usage ----------------------------------------------------------------
Future<Usage> usage() async => Usage.fromJson(_obj(await _get('/api/usage'))); Future<Usage> usage() async => Usage.fromJson(_obj(await _get('/api/usage')));
......
This diff is collapsed.
...@@ -54,12 +54,25 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -54,12 +54,25 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
String? _effort; String? _effort;
final List<Attachment> _pending = []; final List<Attachment> _pending = [];
bool _landed = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(initial: true); _load(initial: true);
} }
/// Opening a chat should land on the newest message. Jumping (not animating)
/// on the first layout avoids the reader watching a long transcript scroll
/// past before it settles.
void _jumpToEnd() {
if (_landed || !mounted) return;
_landed = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent);
});
}
@override @override
void dispose() { void dispose() {
_stopLive(); _stopLive();
...@@ -216,6 +229,21 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -216,6 +229,21 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
} }
} }
/// Persists the composer's pick onto the chat itself. Held only in widget
/// state it was lost the moment the screen was rebuilt, which is why the next
/// message silently reverted to Auto.
Future<void> _rememberPreset({String? model, String? effort}) async {
try {
await _api.updateChat(
widget.chatId,
model: model ?? _model ?? '',
effort: effort ?? _effort ?? '',
);
} catch (_) {
// The choice still applies to this message; persistence is a convenience.
}
}
Future<void> _stop() async { Future<void> _stop() async {
try { try {
await _api.stopChat(widget.chatId); await _api.stopChat(widget.chatId);
...@@ -307,8 +335,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -307,8 +335,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
pending: _pending, pending: _pending,
sending: _sending, sending: _sending,
running: chat?.isRunning ?? false, running: chat?.isRunning ?? false,
onModel: (v) => setState(() => _model = v), onModel: (v) {
onEffort: (v) => setState(() => _effort = v), setState(() => _model = v);
_rememberPreset(model: v);
},
onEffort: (v) {
setState(() => _effort = v);
_rememberPreset(effort: v);
},
onAttach: _attach, onAttach: _attach,
onRemoveAttachment: (a) => setState(() => _pending.remove(a)), onRemoveAttachment: (a) => setState(() => _pending.remove(a)),
onSend: _send, onSend: _send,
...@@ -346,6 +380,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -346,6 +380,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
); );
} }
_jumpToEnd();
return ListView.builder( return ListView.builder(
controller: _scroll, controller: _scroll,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 16), padding: const EdgeInsets.fromLTRB(14, 12, 14, 16),
...@@ -447,15 +482,34 @@ class _Bubble extends StatelessWidget { ...@@ -447,15 +482,34 @@ class _Bubble extends StatelessWidget {
color: message.status == RunStatus.failed ? Ember.bad : Ember.ok, color: message.status == RunStatus.failed ? Ember.bad : Ember.ok,
), ),
const SizedBox(width: 7), const SizedBox(width: 7),
// Always name the model and effort actually used — including
// while running — so an Auto choice is observable, not a guess.
Text( Text(
running ? 'Working…' : (message.model ?? 'claude'), message.model ?? 'claude',
style: const TextStyle(fontSize: 11, color: Ember.textDim), style: const TextStyle(fontSize: 11, color: Ember.textDim),
), ),
if (message.effort != null && message.effort!.isNotEmpty) ...[ if ((message.effort ?? '').isNotEmpty) ...[
const SizedBox(width: 6), const SizedBox(width: 5),
Text('· ${message.effort}', Text('· ${message.effort}',
style: const TextStyle(fontSize: 11, color: Ember.textFaint)), style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
], ],
if (message.routedAuto && (message.routedReason ?? '').isNotEmpty) ...[
const SizedBox(width: 5),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Ember.flame.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Text('auto: ${message.routedReason}',
style: const TextStyle(fontSize: 9.5, color: Ember.flame)),
),
],
if (running) ...[
const SizedBox(width: 6),
const Text('working…',
style: TextStyle(fontSize: 10.5, color: Ember.textFaint)),
],
const Spacer(), const Spacer(),
if (message.content.isNotEmpty) if (message.content.isNotEmpty)
InkWell( InkWell(
......
...@@ -2,13 +2,14 @@ import 'package:flutter/material.dart'; ...@@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../caprover/caprover_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';
import '../settings/settings_screen.dart'; import '../settings/settings_screen.dart';
import '../webview/vscode_screen.dart'; import '../webview/vscode_screen.dart';
/// The five tabs, held in an IndexedStack so each keeps its scroll position, /// The six tabs, held in an IndexedStack so each keeps its scroll position,
/// its open directory and its in-flight stream when you switch away — the whole /// its open directory and its in-flight stream when you switch away — the whole
/// point of the app is leaving work running and coming back to it. /// point of the app is leaving work running and coming back to it.
class HomeShell extends ConsumerStatefulWidget { class HomeShell extends ConsumerStatefulWidget {
...@@ -26,6 +27,7 @@ class _HomeShellState extends ConsumerState<HomeShell> { ...@@ -26,6 +27,7 @@ class _HomeShellState extends ConsumerState<HomeShell> {
ChatsScreen(), ChatsScreen(),
FilesScreen(), FilesScreen(),
GitScreen(), GitScreen(),
CapRoverScreen(),
SettingsScreen(), SettingsScreen(),
]; ];
...@@ -61,6 +63,11 @@ class _HomeShellState extends ConsumerState<HomeShell> { ...@@ -61,6 +63,11 @@ class _HomeShellState extends ConsumerState<HomeShell> {
selectedIcon: Icon(Icons.account_tree), selectedIcon: Icon(Icons.account_tree),
label: 'Git', label: 'Git',
), ),
NavigationDestination(
icon: Icon(Icons.dns_outlined),
selectedIcon: Icon(Icons.dns),
label: 'Deploy',
),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.settings_outlined), icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings), selectedIcon: Icon(Icons.settings),
......
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