neoagent 2.4.4-beta.0 → 2.4.4-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.env.example +17 -0
  2. package/README.md +9 -3
  3. package/docs/capabilities.md +16 -7
  4. package/docs/configuration.md +1 -0
  5. package/docs/getting-started.md +6 -0
  6. package/docs/index.md +1 -0
  7. package/docs/security-boundaries.md +122 -0
  8. package/docs/supermemory-memory-review.md +852 -0
  9. package/flutter_app/lib/features/memory/views/retrieval_inspector_view.dart +128 -0
  10. package/flutter_app/lib/main.dart +3 -0
  11. package/flutter_app/lib/main_account_settings.dart +79 -15
  12. package/flutter_app/lib/main_app_shell.dart +22 -0
  13. package/flutter_app/lib/main_chat.dart +155 -8
  14. package/flutter_app/lib/main_controller.dart +74 -5
  15. package/flutter_app/lib/main_devices.dart +9 -3
  16. package/flutter_app/lib/main_models.dart +32 -0
  17. package/flutter_app/lib/main_operations.dart +13 -0
  18. package/flutter_app/lib/main_security.dart +967 -0
  19. package/flutter_app/lib/main_settings.dart +56 -0
  20. package/flutter_app/lib/src/backend_client.dart +60 -3
  21. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +2 -0
  22. package/flutter_app/pubspec.lock +32 -0
  23. package/flutter_app/pubspec.yaml +1 -0
  24. package/lib/install_helpers.js +1 -0
  25. package/lib/manager.js +63 -1
  26. package/lib/schema_migrations.js +262 -0
  27. package/package.json +4 -2
  28. package/server/admin/admin.js +151 -0
  29. package/server/admin/index.html +55 -3
  30. package/server/db/database.js +3 -0
  31. package/server/http/routes.js +2 -1
  32. package/server/public/.last_build_id +1 -1
  33. package/server/public/assets/NOTICES +86 -0
  34. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  35. package/server/public/flutter_bootstrap.js +1 -1
  36. package/server/public/main.dart.js +82243 -80308
  37. package/server/routes/account.js +2 -23
  38. package/server/routes/admin.js +18 -2
  39. package/server/routes/agents.js +5 -1
  40. package/server/routes/memory.js +41 -4
  41. package/server/routes/security.js +112 -0
  42. package/server/services/account/service_email_settings.js +167 -0
  43. package/server/services/ai/engine.js +269 -27
  44. package/server/services/ai/rate_limits.js +150 -0
  45. package/server/services/ai/systemPrompt.js +26 -3
  46. package/server/services/ai/tools.js +11 -8
  47. package/server/services/cli/shell_worker.js +135 -0
  48. package/server/services/cli/shell_worker_pool.js +125 -0
  49. package/server/services/integrations/google/gmail.js +7 -7
  50. package/server/services/manager.js +20 -1
  51. package/server/services/memory/consolidation.js +111 -0
  52. package/server/services/memory/embedding_index.js +175 -0
  53. package/server/services/memory/embeddings.js +22 -2
  54. package/server/services/memory/evaluation.js +187 -0
  55. package/server/services/memory/ingestion_chunking.js +191 -0
  56. package/server/services/memory/ingestion_documents.js +96 -26
  57. package/server/services/memory/intelligence.js +3 -1
  58. package/server/services/memory/manager.js +855 -43
  59. package/server/services/memory/policy.js +0 -40
  60. package/server/services/memory/retrieval_reasoning.js +191 -0
  61. package/server/services/runtime/manager.js +7 -0
  62. package/server/services/security/approval_gate_service.js +93 -0
  63. package/server/services/security/tool_categories.js +105 -0
  64. package/server/services/security/tool_policy_service.js +92 -0
  65. package/server/services/security/tool_security_hook.js +77 -0
  66. package/server/services/websocket.js +5 -1
@@ -0,0 +1,128 @@
1
+ import 'package:flutter/material.dart';
2
+ import '../../../main.dart'; // To access NeoAgentController
3
+
4
+ class RetrievalInspectorView extends StatefulWidget {
5
+ final NeoAgentController controller;
6
+
7
+ const RetrievalInspectorView({super.key, required this.controller});
8
+
9
+ @override
10
+ State<RetrievalInspectorView> createState() => _RetrievalInspectorViewState();
11
+ }
12
+
13
+ class _RetrievalInspectorViewState extends State<RetrievalInspectorView> {
14
+ final _queryController = TextEditingController();
15
+ bool _isLoading = false;
16
+ Map<String, dynamic>? _results;
17
+ String? _error;
18
+
19
+ @override
20
+ void dispose() {
21
+ _queryController.dispose();
22
+ super.dispose();
23
+ }
24
+
25
+ Future<void> _inspect() async {
26
+ final query = _queryController.text.trim();
27
+ if (query.isEmpty) return;
28
+
29
+ setState(() {
30
+ _isLoading = true;
31
+ _error = null;
32
+ });
33
+
34
+ try {
35
+ final res = await widget.controller.inspectMemory(query);
36
+ if (!mounted) return;
37
+ setState(() {
38
+ _results = res;
39
+ });
40
+ } catch (e) {
41
+ if (!mounted) return;
42
+ setState(() {
43
+ _error = e.toString();
44
+ });
45
+ } finally {
46
+ if (mounted) {
47
+ setState(() {
48
+ _isLoading = false;
49
+ });
50
+ }
51
+ }
52
+ }
53
+
54
+ @override
55
+ Widget build(BuildContext context) {
56
+ return Scaffold(
57
+ appBar: AppBar(
58
+ title: const Text('Retrieval Inspector'),
59
+ ),
60
+ body: Padding(
61
+ padding: const EdgeInsets.all(16.0),
62
+ child: Column(
63
+ children: [
64
+ Row(
65
+ children: [
66
+ Expanded(
67
+ child: TextField(
68
+ controller: _queryController,
69
+ decoration: const InputDecoration(
70
+ labelText: 'Query',
71
+ border: OutlineInputBorder(),
72
+ ),
73
+ onSubmitted: (_) => _inspect(),
74
+ ),
75
+ ),
76
+ const SizedBox(width: 8),
77
+ ElevatedButton(
78
+ onPressed: _isLoading ? null : _inspect,
79
+ child: const Text('Inspect'),
80
+ ),
81
+ ],
82
+ ),
83
+ const SizedBox(height: 16),
84
+ if (_isLoading)
85
+ const CircularProgressIndicator()
86
+ else if (_error != null)
87
+ Text('Error: $_error', style: const TextStyle(color: Colors.red))
88
+ else if (_results != null)
89
+ Expanded(
90
+ child: ListView.builder(
91
+ itemCount: (_results!['results'] as List?)?.length ?? 0,
92
+ itemBuilder: (context, index) {
93
+ final item = _results!['results'][index];
94
+ final breakdown = item['scoreBreakdown'] ?? {};
95
+ return Card(
96
+ margin: const EdgeInsets.only(bottom: 8),
97
+ child: Padding(
98
+ padding: const EdgeInsets.all(12.0),
99
+ child: Column(
100
+ crossAxisAlignment: CrossAxisAlignment.start,
101
+ children: [
102
+ Text(
103
+ 'Score: ${item['score']?.toStringAsFixed(3) ?? '?'}',
104
+ style: const TextStyle(fontWeight: FontWeight.bold),
105
+ ),
106
+ Text('Content: ${item['content']}'),
107
+ const SizedBox(height: 8),
108
+ const Text('Breakdown:', style: TextStyle(fontWeight: FontWeight.bold)),
109
+ Text('Semantic: ${breakdown['semantic']?.toStringAsFixed(3)}'),
110
+ Text('Lexical: ${breakdown['lexical']?.toStringAsFixed(3)}'),
111
+ Text('Full Text (FTS): ${breakdown['fullText']?.toStringAsFixed(3)}'),
112
+ Text('Entity: ${breakdown['entity']?.toStringAsFixed(3)}'),
113
+ Text('Relation: ${breakdown['relation']?.toStringAsFixed(3)}'),
114
+ Text('Candidate Count: ${breakdown['candidateCount']}'),
115
+ Text('Vector Rank: ${breakdown['vectorCandidateRank']}'),
116
+ ],
117
+ ),
118
+ ),
119
+ );
120
+ },
121
+ ),
122
+ ),
123
+ ],
124
+ ),
125
+ ),
126
+ );
127
+ }
128
+ }
@@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart';
11
11
  import 'package:flutter/gestures.dart';
12
12
  import 'package:flutter/material.dart';
13
13
  import 'package:flutter/services.dart';
14
+ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
14
15
  import 'package:flutter_markdown/flutter_markdown.dart';
15
16
  import 'package:file_picker/file_picker.dart';
16
17
  import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -48,6 +49,7 @@ import 'src/android_auto_bridge.dart';
48
49
  import 'features/location/location_service.dart';
49
50
  import 'features/notifications/notification_interceptor.dart';
50
51
  import 'features/onboarding/onboarding_shell.dart';
52
+ import 'features/memory/views/retrieval_inspector_view.dart';
51
53
 
52
54
  part 'main_spacing.dart';
53
55
  part 'main_theme.dart';
@@ -65,6 +67,7 @@ part 'main_recordings.dart';
65
67
  part 'main_chat.dart';
66
68
  part 'main_account_settings.dart';
67
69
  part 'main_settings.dart';
70
+ part 'main_security.dart';
68
71
  part 'main_model_picker.dart';
69
72
  part 'main_operations.dart';
70
73
  part 'main_admin.dart';
@@ -403,7 +403,8 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
403
403
  controller: _displayNameController,
404
404
  decoration: const InputDecoration(
405
405
  labelText: 'Display name',
406
- helperText: 'Shown in the sidebar. Leave blank to use your username.',
406
+ helperText:
407
+ 'Shown in the sidebar. Leave blank to use your username.',
407
408
  ),
408
409
  ),
409
410
  if (_displayNameInlineError != null) ...<Widget>[
@@ -607,6 +608,16 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
607
608
  return amount.toString();
608
609
  }
609
610
 
611
+ String? _nextUsageDropLabel(DateTime? value) {
612
+ if (value == null) return null;
613
+ final remaining = value.difference(DateTime.now());
614
+ if (remaining.isNegative) return 'Usage updates shortly';
615
+ if (remaining.inHours > 0) {
616
+ return 'Next usage drop in ${remaining.inHours}h ${remaining.inMinutes.remainder(60)}m';
617
+ }
618
+ return 'Next usage drop in ${remaining.inMinutes + 1}m';
619
+ }
620
+
610
621
  Widget _buildUsagePanel() {
611
622
  if (widget.controller.isLoadingAccountSettings) {
612
623
  return const Center(
@@ -616,7 +627,7 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
616
627
  ),
617
628
  );
618
629
  }
619
-
630
+
620
631
  final usage = widget.controller.usageAndLimits;
621
632
  if (usage == null) {
622
633
  return Center(
@@ -639,12 +650,21 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
639
650
  );
640
651
  }
641
652
 
642
- Widget buildStatBox(String label, int current, int? limit, {bool isCustom = false}) {
653
+ Widget buildStatBox(
654
+ String label,
655
+ int current,
656
+ int? limit, {
657
+ required int remaining,
658
+ required bool reached,
659
+ required DateTime? nextDecreaseAt,
660
+ bool isCustom = false,
661
+ }) {
643
662
  final double progress = limit != null
644
663
  ? (limit <= 0 ? 1.0 : (current / limit).clamp(0.0, 1.0))
645
664
  : 0.0;
646
665
  final bool nearLimit = progress > 0.8;
647
- final bool atLimit = progress >= 1.0;
666
+ final bool atLimit = reached || progress >= 1.0;
667
+ final nextDrop = _nextUsageDropLabel(nextDecreaseAt);
648
668
 
649
669
  return Container(
650
670
  width: double.infinity,
@@ -656,8 +676,8 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
656
676
  color: atLimit
657
677
  ? _danger.withValues(alpha: 0.6)
658
678
  : nearLimit
659
- ? _warning.withValues(alpha: 0.5)
660
- : _borderLight,
679
+ ? _warning.withValues(alpha: 0.5)
680
+ : _borderLight,
661
681
  ),
662
682
  ),
663
683
  child: Column(
@@ -665,11 +685,20 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
665
685
  children: <Widget>[
666
686
  Row(
667
687
  children: <Widget>[
668
- Text(label, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
688
+ Text(
689
+ label,
690
+ style: const TextStyle(
691
+ fontSize: 16,
692
+ fontWeight: FontWeight.w600,
693
+ ),
694
+ ),
669
695
  const Spacer(),
670
696
  if (limit != null)
671
697
  Container(
672
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
698
+ padding: const EdgeInsets.symmetric(
699
+ horizontal: 8,
700
+ vertical: 3,
701
+ ),
673
702
  decoration: BoxDecoration(
674
703
  color: isCustom
675
704
  ? _warning.withValues(alpha: 0.12)
@@ -696,7 +725,11 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
696
725
  style: TextStyle(
697
726
  fontSize: 28,
698
727
  fontWeight: FontWeight.w800,
699
- color: atLimit ? _danger : nearLimit ? _warning : null,
728
+ color: atLimit
729
+ ? _danger
730
+ : nearLimit
731
+ ? _warning
732
+ : null,
700
733
  ),
701
734
  ),
702
735
  Padding(
@@ -728,16 +761,27 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
728
761
  value: progress,
729
762
  backgroundColor: _border,
730
763
  valueColor: AlwaysStoppedAnimation<Color>(
731
- atLimit ? _danger : nearLimit ? _warning : _accent,
764
+ atLimit
765
+ ? _danger
766
+ : nearLimit
767
+ ? _warning
768
+ : _accent,
732
769
  ),
733
770
  ),
734
771
  ),
735
772
  const SizedBox(height: 6),
736
773
  Text(
737
- '${(progress * 100).toStringAsFixed(0)}% used',
774
+ atLimit
775
+ ? 'Limit reached${nextDrop == null ? '' : ' · $nextDrop'}'
776
+ : '${(progress * 100).toStringAsFixed(0)}% used · ${_formatTokens(remaining)} remaining'
777
+ '${nextDrop == null ? '' : ' · $nextDrop'}',
738
778
  style: TextStyle(
739
779
  fontSize: 11,
740
- color: atLimit ? _danger : nearLimit ? _warning : _textMuted,
780
+ color: atLimit
781
+ ? _danger
782
+ : nearLimit
783
+ ? _warning
784
+ : _textMuted,
741
785
  ),
742
786
  ),
743
787
  ],
@@ -756,9 +800,25 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
756
800
  style: TextStyle(color: _textSecondary, height: 1.4),
757
801
  ),
758
802
  const SizedBox(height: 24),
759
- buildStatBox('Recent Usage (4 Hours)', usage.fourHourUsage, usage.fourHourLimit, isCustom: usage.fourHourIsCustom),
803
+ buildStatBox(
804
+ 'Recent Usage (4 Hours)',
805
+ usage.fourHourUsage,
806
+ usage.fourHourLimit,
807
+ remaining: usage.fourHourRemaining,
808
+ reached: usage.fourHourReached,
809
+ nextDecreaseAt: usage.fourHourNextDecreaseAt,
810
+ isCustom: usage.fourHourIsCustom,
811
+ ),
760
812
  const SizedBox(height: 16),
761
- buildStatBox('Weekly Usage', usage.weeklyUsage, usage.weeklyLimit, isCustom: usage.weeklyIsCustom),
813
+ buildStatBox(
814
+ 'Weekly Usage',
815
+ usage.weeklyUsage,
816
+ usage.weeklyLimit,
817
+ remaining: usage.weeklyRemaining,
818
+ reached: usage.weeklyReached,
819
+ nextDecreaseAt: usage.weeklyNextDecreaseAt,
820
+ isCustom: usage.weeklyIsCustom,
821
+ ),
762
822
  ],
763
823
  );
764
824
  }
@@ -1128,7 +1188,11 @@ class _AccountSettingsTabs extends StatelessWidget {
1128
1188
  Widget build(BuildContext context) {
1129
1189
  final buttons = <Widget>[
1130
1190
  _tabButton(AccountSettingsTab.account, Icons.person_outline, 'Account'),
1131
- _tabButton(AccountSettingsTab.usage, Icons.data_usage_outlined, 'Usage & Limits'),
1191
+ _tabButton(
1192
+ AccountSettingsTab.usage,
1193
+ Icons.data_usage_outlined,
1194
+ 'Usage & Limits',
1195
+ ),
1132
1196
  _tabButton(
1133
1197
  AccountSettingsTab.security,
1134
1198
  Icons.security_outlined,
@@ -1086,6 +1086,7 @@ class HomeView extends StatefulWidget {
1086
1086
 
1087
1087
  class _HomeViewState extends State<HomeView> {
1088
1088
  bool _blockedDialogOpen = false;
1089
+ bool _approvalSheetOpen = false;
1089
1090
  SidebarGroup? _expandedSidebarGroup;
1090
1091
  AppSection? _lastSelectedSection;
1091
1092
  final GlobalKey _devicesPanelKey = GlobalKey();
@@ -1198,6 +1199,27 @@ class _HomeViewState extends State<HomeView> {
1198
1199
  _showBlockedSenderDialog(pendingBlockedSender);
1199
1200
  });
1200
1201
  }
1202
+ final pendingApproval = controller.pendingApproval;
1203
+ if (!_approvalSheetOpen && pendingApproval != null) {
1204
+ _approvalSheetOpen = true;
1205
+ WidgetsBinding.instance.addPostFrameCallback((_) {
1206
+ if (!mounted) return;
1207
+ showModalBottomSheet<void>(
1208
+ context: context,
1209
+ isDismissible: false,
1210
+ enableDrag: false,
1211
+ shape: const RoundedRectangleBorder(
1212
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
1213
+ ),
1214
+ builder: (_) => ToolApprovalSheet(
1215
+ request: pendingApproval,
1216
+ controller: controller,
1217
+ ),
1218
+ ).whenComplete(() {
1219
+ if (mounted) setState(() => _approvalSheetOpen = false);
1220
+ });
1221
+ });
1222
+ }
1201
1223
 
1202
1224
  final wide = MediaQuery.sizeOf(context).width >= 1080;
1203
1225
 
@@ -295,12 +295,13 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
295
295
  transitionBuilder: (ctx, animation, secondary, child) => FadeTransition(
296
296
  opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
297
297
  child: SlideTransition(
298
- position: Tween<Offset>(
299
- begin: const Offset(0, 0.04),
300
- end: Offset.zero,
301
- ).animate(
302
- CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
303
- ),
298
+ position:
299
+ Tween<Offset>(
300
+ begin: const Offset(0, 0.04),
301
+ end: Offset.zero,
302
+ ).animate(
303
+ CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
304
+ ),
304
305
  child: child,
305
306
  ),
306
307
  ),
@@ -398,6 +399,11 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
398
399
  _maybeFollowChatContent(messages, controller);
399
400
 
400
401
  final threadChildren = <Widget>[
402
+ if (controller.usageAndLimits case final usage?
403
+ when usage.hasLimits) ...<Widget>[
404
+ _RateLimitStatusCard(usage: usage),
405
+ const SizedBox(height: 16),
406
+ ],
401
407
  if (controller.errorMessage != null) ...<Widget>[
402
408
  _InlineError(message: controller.errorMessage!),
403
409
  const SizedBox(height: 16),
@@ -471,8 +477,12 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
471
477
  SelectionArea(
472
478
  child: ListView(
473
479
  controller: _scrollController,
474
- padding:
475
- EdgeInsets.fromLTRB(sidePadding, 30, sidePadding, 18),
480
+ padding: EdgeInsets.fromLTRB(
481
+ sidePadding,
482
+ 30,
483
+ sidePadding,
484
+ 18,
485
+ ),
476
486
  children: <Widget>[
477
487
  Center(
478
488
  child: ConstrainedBox(
@@ -715,6 +725,143 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
715
725
  }
716
726
  }
717
727
 
728
+ class _RateLimitStatusCard extends StatelessWidget {
729
+ const _RateLimitStatusCard({required this.usage});
730
+
731
+ final AccountUsageAndLimits usage;
732
+
733
+ String _formatTokens(int amount) {
734
+ if (amount >= 1000000) {
735
+ final value = amount / 1000000;
736
+ return '${value.toStringAsFixed(value == value.roundToDouble() ? 0 : 1)}M';
737
+ }
738
+ if (amount >= 1000) {
739
+ final value = amount / 1000;
740
+ return '${value.toStringAsFixed(value == value.roundToDouble() ? 0 : 1)}k';
741
+ }
742
+ return amount.toString();
743
+ }
744
+
745
+ String? _nextDropLabel(DateTime? value) {
746
+ if (value == null) return null;
747
+ final remaining = value.difference(DateTime.now());
748
+ if (remaining.isNegative) return 'Usage updates shortly';
749
+ if (remaining.inHours > 0) {
750
+ return 'Next usage drop in ${remaining.inHours}h ${remaining.inMinutes.remainder(60)}m';
751
+ }
752
+ return 'Next usage drop in ${remaining.inMinutes + 1}m';
753
+ }
754
+
755
+ Widget _buildWindow({
756
+ required String label,
757
+ required int usageAmount,
758
+ required int? limit,
759
+ required int remaining,
760
+ required bool reached,
761
+ required DateTime? nextDecreaseAt,
762
+ }) {
763
+ if (limit == null || limit <= 0) return const SizedBox.shrink();
764
+ final progress = (usageAmount / limit).clamp(0.0, 1.0);
765
+ final color = reached
766
+ ? _danger
767
+ : progress >= 0.8
768
+ ? _warning
769
+ : _accent;
770
+ final nextDrop = _nextDropLabel(nextDecreaseAt);
771
+ return Column(
772
+ crossAxisAlignment: CrossAxisAlignment.start,
773
+ children: <Widget>[
774
+ Row(
775
+ children: <Widget>[
776
+ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
777
+ const Spacer(),
778
+ Text(
779
+ reached ? 'Limit reached' : '${_formatTokens(remaining)} left',
780
+ style: TextStyle(
781
+ color: color,
782
+ fontSize: 12,
783
+ fontWeight: FontWeight.w600,
784
+ ),
785
+ ),
786
+ ],
787
+ ),
788
+ const SizedBox(height: 8),
789
+ ClipRRect(
790
+ borderRadius: BorderRadius.circular(999),
791
+ child: LinearProgressIndicator(
792
+ minHeight: 7,
793
+ value: progress,
794
+ backgroundColor: _border,
795
+ valueColor: AlwaysStoppedAnimation<Color>(color),
796
+ ),
797
+ ),
798
+ const SizedBox(height: 6),
799
+ Text(
800
+ '${_formatTokens(usageAmount)} / ${_formatTokens(limit)} tokens'
801
+ '${nextDrop == null ? '' : ' · $nextDrop'}',
802
+ style: TextStyle(color: _textMuted, fontSize: 11),
803
+ ),
804
+ ],
805
+ );
806
+ }
807
+
808
+ @override
809
+ Widget build(BuildContext context) {
810
+ final windows = <Widget>[
811
+ _buildWindow(
812
+ label: '4-hour usage',
813
+ usageAmount: usage.fourHourUsage,
814
+ limit: usage.fourHourLimit,
815
+ remaining: usage.fourHourRemaining,
816
+ reached: usage.fourHourReached,
817
+ nextDecreaseAt: usage.fourHourNextDecreaseAt,
818
+ ),
819
+ _buildWindow(
820
+ label: '7-day usage',
821
+ usageAmount: usage.weeklyUsage,
822
+ limit: usage.weeklyLimit,
823
+ remaining: usage.weeklyRemaining,
824
+ reached: usage.weeklyReached,
825
+ nextDecreaseAt: usage.weeklyNextDecreaseAt,
826
+ ),
827
+ ];
828
+ return Container(
829
+ width: double.infinity,
830
+ padding: const EdgeInsets.all(14),
831
+ decoration: BoxDecoration(
832
+ color: usage.isReached ? _danger.withValues(alpha: 0.08) : _bgSecondary,
833
+ borderRadius: BorderRadius.circular(16),
834
+ border: Border.all(
835
+ color: usage.isReached
836
+ ? _danger.withValues(alpha: 0.55)
837
+ : _borderLight,
838
+ ),
839
+ ),
840
+ child: LayoutBuilder(
841
+ builder: (context, constraints) {
842
+ if (constraints.maxWidth < 620) {
843
+ return Column(
844
+ children: <Widget>[
845
+ windows.first,
846
+ const SizedBox(height: 14),
847
+ windows.last,
848
+ ],
849
+ );
850
+ }
851
+ return Row(
852
+ crossAxisAlignment: CrossAxisAlignment.start,
853
+ children: <Widget>[
854
+ Expanded(child: windows.first),
855
+ const SizedBox(width: 24),
856
+ Expanded(child: windows.last),
857
+ ],
858
+ );
859
+ },
860
+ ),
861
+ );
862
+ }
863
+ }
864
+
718
865
  double _chatSidePadding(BuildContext context) {
719
866
  final width = MediaQuery.sizeOf(context).width;
720
867
  if (width >= 1280) return 40;