Commit d7ff664f authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(chat): plan-first toggle and an approve step

A toggle in the chat's app bar; when on, every message describes what it would
do and changes nothing. A finished plan grows two buttons: approve and run it,
or revise it.

Approving resumes the same Claude session with plan mode off, so the plan is
context rather than a second question. Revising just seeds the composer, because
the useful correction is almost always a sentence, not a new prompt.

The approve bar appears only under the newest assistant turn, only when that
turn actually ran as a plan, and only once it has finished — offering to approve
something still being written would approve a plan nobody has read.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 3c3cb85f
......@@ -111,6 +111,7 @@ class ChatMessage {
this.exitCode,
this.routedAuto = false,
this.routedReason,
this.wasPlan = false,
});
final String id;
......@@ -130,6 +131,10 @@ class ChatMessage {
final bool routedAuto;
final String? routedReason;
/// This turn ran in plan mode: it described what it would do and changed
/// nothing. The app offers to approve it.
final bool wasPlan;
bool get isUser => role == 'user';
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
......@@ -145,6 +150,7 @@ class ChatMessage {
exitCode: _iOrNull(j['exitCode']),
routedAuto: _b(j['routedAuto']),
routedReason: j['routedReason'] == null ? null : _s(j['routedReason']),
wasPlan: _b(j['wasPlan']),
);
ChatMessage copyWith({String? content, RunStatus? status}) => ChatMessage(
......@@ -160,6 +166,7 @@ class ChatMessage {
exitCode: exitCode,
routedAuto: routedAuto,
routedReason: routedReason,
wasPlan: wasPlan,
);
}
......@@ -175,6 +182,7 @@ class Chat {
this.model,
this.effort,
this.lastPrompt = '',
this.planFirst = false,
this.archived = false,
this.messages = const [],
this.attachments = const [],
......@@ -190,6 +198,10 @@ class Chat {
final String? model;
final String? effort;
final String lastPrompt;
/// Every message in this chat runs as a plan first, changing nothing until
/// approved.
final bool planFirst;
final bool archived;
final List<ChatMessage> messages;
final List<Attachment> attachments;
......@@ -207,6 +219,7 @@ class Chat {
model: j['model'] == null ? null : _s(j['model']),
effort: j['effort'] == null ? null : _s(j['effort']),
lastPrompt: _s(j['lastPrompt']),
planFirst: _b(j['planFirst']),
archived: _b(j['archived']),
messages: _list(j['messages'], ChatMessage.fromJson),
attachments: _list(j['attachments'], Attachment.fromJson),
......
......@@ -169,12 +169,14 @@ class SonApi {
String? model,
String? effort,
bool? archived,
bool? planFirst,
}) async =>
Chat.fromJson(_obj(await _send('PATCH', '/api/chats/$id', body: {
if (title != null) 'title': title,
if (model != null) 'model': model,
if (effort != null) 'effort': effort,
if (archived != null) 'archived': archived,
if (planFirst != null) 'planFirst': planFirst,
})));
Future<void> deleteChat(String id) async => _send('DELETE', '/api/chats/$id');
......@@ -188,12 +190,16 @@ class SonApi {
String? model,
String? effort,
List<String> attachments = const [],
bool? plan,
}) async {
final j = _obj(await _send('POST', '/api/chats/$chatId/messages', body: {
'text': text,
if (model != null) 'model': model,
if (effort != null) 'effort': effort,
if (attachments.isNotEmpty) 'attachments': attachments,
// Explicit false is an approval and overrides the chat's plan-first
// setting; omitting it means "use the chat default".
if (plan != null) 'plan': plan,
}));
return (j['taskId'] ?? '').toString();
}
......
......@@ -265,6 +265,41 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
}
/// Approves a plan: resumes the same session with plan mode off, so the plan
/// is already in context and the agent executes it rather than re-deriving it.
Future<void> _approvePlan() async {
setState(() => _sending = true);
try {
await _api.sendMessage(
widget.chatId,
text: 'Approved — carry out that plan.',
plan: false,
);
_liveText = '';
HapticFeedback.mediumImpact();
await _load();
} on ApiException catch (e) {
if (mounted) _toast(e.message, bad: true);
} finally {
if (mounted) setState(() => _sending = false);
}
}
Future<void> _togglePlanFirst() async {
final now = !(_chat?.planFirst ?? false);
try {
await _api.updateChat(widget.chatId, planFirst: now);
await _load();
if (mounted) {
_toast(now
? 'Plan first: messages describe changes before making them'
: 'Plan first off: messages act immediately');
}
} on ApiException catch (e) {
if (mounted) _toast(e.message, bad: true);
}
}
Future<void> _stop() async {
try {
await _api.stopChat(widget.chatId);
......@@ -342,6 +377,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
icon: const Icon(Icons.stop_circle_outlined, color: Ember.bad),
onPressed: _stop,
),
IconButton(
tooltip: (chat?.planFirst ?? false) ? 'Plan first: on' : 'Plan first: off',
icon: Icon(
(chat?.planFirst ?? false) ? Icons.fact_check : Icons.fact_check_outlined,
color: (chat?.planFirst ?? false) ? Ember.flame : null,
),
onPressed: _togglePlanFirst,
),
IconButton(icon: const Icon(Icons.edit_outlined), onPressed: _rename),
],
),
......@@ -437,13 +480,57 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
// supplies everything else.
final isLast = i == msgs.length - 1;
final isLive = isLast && !m.isUser && chat.isRunning;
final awaitingApproval =
isLast && !m.isUser && !chat.isRunning && m.wasPlan && m.content.isNotEmpty;
// While running, show whichever of the two live sources has more than
// the stored message body — that is the feed.
final body = isLive && _liveText.length > m.content.length ? _liveText : m.content;
return _Bubble(
final bubble = _Bubble(
message: body == m.content ? m : m.copyWith(content: body),
running: isLive,
);
if (!awaitingApproval) return bubble;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
bubble,
// A plan changed nothing. This is the only place the run becomes
// real, which is the entire point of plan-first.
Padding(
padding: const EdgeInsets.only(bottom: 14, right: 24),
child: Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _sending ? null : _approvePlan,
icon: const Icon(Icons.play_arrow, size: 17),
label: const Text('Approve & run'),
style: FilledButton.styleFrom(
backgroundColor: Ember.flame,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _sending ? null : () => _input.text = 'Change the plan: ',
icon: const Icon(Icons.edit_note, size: 17),
label: const Text('Revise'),
style: OutlinedButton.styleFrom(
foregroundColor: Ember.textDim,
side: const BorderSide(color: Ember.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
),
],
),
),
],
);
},
);
}
......
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