Commit 7f21d159 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(chat): floating jump-to-latest, and honest usage freshness

JUMP TO LATEST. A WhatsApp-style button floating over the transcript — not in
the composer, not in the app bar — that appears only once you are meaningfully
above the newest message, so it is not permanent clutter. It carries a dot while
a run is live, because that is when output is arriving off-screen and the button
is worth pressing.

USAGE. The refresh button now forces past the server cache, and the card says
how old the figures are: "as of 12m ago · updates when a task runs". Claude
reports utilisation only inside a run, so a refresh genuinely cannot produce new
numbers on its own — showing a spinner that resolves to the same values is what
made the button look broken. When there is nothing yet, the server's own
explanation is shown instead of a generic line.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent a0d8b3b8
......@@ -414,11 +414,17 @@ class UsageWindow {
}
class Usage {
const Usage({required this.known, this.fiveHour, this.week});
const Usage({required this.known, this.fiveHour, this.week, this.updatedAt, this.reason = ''});
final bool known;
final UsageWindow? fiveHour;
final UsageWindow? week;
/// When these numbers were captured, and — when they are absent — why. Claude
/// only reports utilisation during a run, so "unknown" is a real state that
/// deserves an explanation rather than an empty bar.
final DateTime? updatedAt;
final String reason;
factory Usage.fromJson(Map<String, dynamic> j) {
Map<String, dynamic>? m(dynamic v) => v is Map ? v.cast<String, dynamic>() : null;
final five = m(j['fiveHour']);
......@@ -427,6 +433,8 @@ class Usage {
known: _b(j['limitsKnown']),
fiveHour: five == null ? null : UsageWindow.fromJson(five),
week: wk == null ? null : UsageWindow.fromJson(wk),
updatedAt: _s(j['updatedAt']).isEmpty ? null : _dt(j['updatedAt']),
reason: _s(j['reason']),
);
}
}
......
......@@ -343,7 +343,9 @@ class SonApi {
// --- usage ----------------------------------------------------------------
Future<Usage> usage() async => Usage.fromJson(_obj(await _get('/api/usage')));
/// [force] bypasses the server's 60s cache — the Settings refresh button.
Future<Usage> usage({bool force = false}) async =>
Usage.fromJson(_obj(await _get('/api/usage', force ? {'force': '1'} : null)));
// --- streaming ------------------------------------------------------------
//
......
......@@ -110,7 +110,10 @@ final effectiveProjectProvider = Provider<String>((ref) {
return (projects == null || projects.isEmpty) ? '' : projects.first.name;
});
final usageProvider = FutureProvider<Usage>((ref) async => ref.watch(apiProvider).usage());
/// Force-refreshes: this provider is only ever read on the Settings screen and
/// invalidated by its refresh button, so a cached answer defeats the point.
final usageProvider =
FutureProvider<Usage>((ref) async => ref.watch(apiProvider).usage(force: true));
final gitStatusProvider = FutureProvider<List<RepoStatus>>((ref) async {
return ref.watch(apiProvider).gitStatusAll();
......
......@@ -55,13 +55,33 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
final List<Attachment> _pending = [];
bool _landed = false;
bool _showJump = false;
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
_load(initial: true);
}
/// The jump button earns its place only once you are meaningfully above the
/// newest message — showing it at the bottom would be permanent clutter.
void _onScroll() {
if (!_scroll.hasClients) return;
final away = _scroll.position.maxScrollExtent - _scroll.position.pixels;
final show = away > 320;
if (show != _showJump) setState(() => _showJump = show);
}
void _scrollToEnd() {
if (!_scroll.hasClients) return;
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
/// 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.
......@@ -75,6 +95,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
@override
void dispose() {
_scroll.removeListener(_onScroll);
_stopLive();
_input.dispose();
_scroll.dispose();
......@@ -326,7 +347,32 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
),
body: Column(
children: [
Expanded(child: _body(chat)),
Expanded(
child: Stack(
children: [
Positioned.fill(child: _body(chat)),
Positioned(
right: 14,
bottom: 14,
child: AnimatedSlide(
duration: const Duration(milliseconds: 180),
offset: _showJump ? Offset.zero : const Offset(0, 1.4),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: _showJump ? 1 : 0,
child: IgnorePointer(
ignoring: !_showJump,
child: _JumpButton(
onTap: _scrollToEnd,
running: chat?.isRunning ?? false,
),
),
),
),
),
],
),
),
_Composer(
controller: _input,
vocab: vocab,
......@@ -738,3 +784,51 @@ class _Selector extends StatelessWidget {
),
);
}
/// Jump-to-latest, floating over the transcript. Pulses while a run is live,
/// because that is when new output is arriving off-screen.
class _JumpButton extends StatelessWidget {
const _JumpButton({required this.onTap, required this.running});
final VoidCallback onTap;
final bool running;
@override
Widget build(BuildContext context) {
return Material(
color: Ember.surfaceHi,
shape: const CircleBorder(side: BorderSide(color: Ember.border)),
elevation: 4,
shadowColor: Colors.black54,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 44,
height: 44,
child: Stack(
alignment: Alignment.center,
children: [
const Icon(Icons.keyboard_double_arrow_down, size: 21, color: Ember.text),
if (running)
Positioned(
top: 7,
right: 8,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Ember.flame,
shape: BoxShape.circle,
border: Border.all(color: Ember.surfaceHi, width: 1.5),
),
),
),
],
),
),
),
);
}
}
......@@ -42,15 +42,29 @@ class SettingsScreen extends ConsumerWidget {
error: (e, _) => Text('Unavailable — $e',
style: const TextStyle(color: Ember.textDim, fontSize: 12)),
data: (u) => !u.known
? const Text(
'No limit data yet. It appears after the next run.',
style: TextStyle(color: Ember.textDim, fontSize: 12),
? Text(
u.reason.isEmpty
? 'No limit data yet. It appears after the next run.'
: u.reason,
style: const TextStyle(color: Ember.textDim, fontSize: 12),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_UsageBar(label: 'Five hours', window: u.fiveHour),
const SizedBox(height: 16),
_UsageBar(label: 'Seven days', window: u.week),
if (u.updatedAt != null) ...[
const SizedBox(height: 12),
// Claude only reports utilisation inside a run, so
// these numbers are as of the last one. Saying that
// is better than a refresh button that appears
// broken because nothing changed.
Text(
'as of ${_stamp(u.updatedAt!)} · updates when a task runs',
style: const TextStyle(fontSize: 10, color: Ember.textFaint),
),
],
],
),
),
......@@ -113,6 +127,14 @@ class SettingsScreen extends ConsumerWidget {
}
}
String _stamp(DateTime t) {
final d = DateTime.now().difference(t);
if (d.inMinutes < 1) return 'just now';
if (d.inMinutes < 60) return '${d.inMinutes}m ago';
if (d.inHours < 24) return '${d.inHours}h ago';
return '${d.inDays}d ago';
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
......
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