neoagent 3.1.1-beta.3 → 3.1.1-beta.4

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 (40) hide show
  1. package/extensions/chrome-browser/manifest.json +1 -1
  2. package/extensions/chrome-browser/protocol.mjs +36 -0
  3. package/flutter_app/lib/main_app_shell.dart +9 -3
  4. package/flutter_app/lib/main_controller.dart +32 -0
  5. package/flutter_app/lib/main_settings.dart +301 -1
  6. package/flutter_app/lib/main_shared.dart +7 -2
  7. package/flutter_app/lib/main_timeline.dart +82 -11
  8. package/flutter_app/lib/src/backend_client.dart +30 -0
  9. package/landing/index.html +3 -0
  10. package/landing/status.html +178 -0
  11. package/package.json +1 -1
  12. package/server/http/routes.js +2 -0
  13. package/server/http/static.js +1 -0
  14. package/server/public/.last_build_id +1 -1
  15. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  16. package/server/public/flutter_bootstrap.js +1 -1
  17. package/server/public/main.dart.js +69698 -69237
  18. package/server/routes/public_status.js +12 -0
  19. package/server/routes/settings.js +14 -0
  20. package/server/routes/social_reach.js +88 -0
  21. package/server/services/ai/tools.js +59 -0
  22. package/server/services/browser/extension/protocol.js +1 -0
  23. package/server/services/manager.js +14 -0
  24. package/server/services/public_status.js +103 -0
  25. package/server/services/runtime/settings.js +6 -0
  26. package/server/services/social_reach/channels/base.js +56 -0
  27. package/server/services/social_reach/channels/github.js +75 -0
  28. package/server/services/social_reach/channels/index.js +34 -0
  29. package/server/services/social_reach/channels/linkedin.js +34 -0
  30. package/server/services/social_reach/channels/rss.js +58 -0
  31. package/server/services/social_reach/channels/unsupported.js +49 -0
  32. package/server/services/social_reach/channels/v2ex.js +85 -0
  33. package/server/services/social_reach/channels/web.js +40 -0
  34. package/server/services/social_reach/channels/xueqiu.js +101 -0
  35. package/server/services/social_reach/channels/youtube.js +42 -0
  36. package/server/services/social_reach/index.js +7 -0
  37. package/server/services/social_reach/platforms.js +162 -0
  38. package/server/services/social_reach/service.js +163 -0
  39. package/server/services/social_reach/store.js +89 -0
  40. package/server/services/social_reach/utils.js +95 -0
@@ -5,7 +5,7 @@
5
5
  "version_name": "2.4.1-beta.8",
6
6
  "description": "Connect this Chrome browser to NeoAgent for browser automation.",
7
7
  "minimum_chrome_version": "118",
8
- "permissions": ["alarms", "debugger", "storage", "tabs"],
8
+ "permissions": ["alarms", "cookies", "debugger", "storage", "tabs"],
9
9
  "host_permissions": ["http://*/*", "https://*/*"],
10
10
  "icons": {
11
11
  "16": "icons/icon16.png",
@@ -12,6 +12,7 @@ export const COMMANDS = Object.freeze({
12
12
  SCREENSHOT: 'screenshot',
13
13
  CLOSE: 'close',
14
14
  GET_PAGE_INFO: 'getPageInfo',
15
+ GET_COOKIES: 'getCookies',
15
16
  });
16
17
 
17
18
  function chromeCall(chromeApi, namespace, method, ...args) {
@@ -221,6 +222,41 @@ export function createBrowserProtocol(chromeApi) {
221
222
 
222
223
  async function run(command, payload = {}) {
223
224
  switch (command) {
225
+ case COMMANDS.GET_COOKIES: {
226
+ const domains = Array.isArray(payload.domains)
227
+ ? payload.domains.map((item) => String(item || '').replace(/^\./, '').toLowerCase()).filter(Boolean)
228
+ : [];
229
+ if (domains.length === 0) throw new Error('At least one cookie domain is required.');
230
+ const all = [];
231
+ for (const domain of domains) {
232
+ const cookies = await chromeCall(chromeApi, 'cookies', 'getAll', { domain });
233
+ all.push(...(Array.isArray(cookies) ? cookies : []));
234
+ }
235
+ const seen = new Set();
236
+ const filtered = all.filter((cookie) => {
237
+ const cookieDomain = String(cookie?.domain || '').replace(/^\./, '').toLowerCase();
238
+ const allowed = domains.some((domain) => cookieDomain === domain || cookieDomain.endsWith(`.${domain}`));
239
+ if (!allowed) return false;
240
+ const key = `${cookie.name}\n${cookie.domain}\n${cookie.path}`;
241
+ if (seen.has(key)) return false;
242
+ seen.add(key);
243
+ return true;
244
+ });
245
+ return {
246
+ platform: String(payload.platform || ''),
247
+ domains,
248
+ cookies: filtered.map((cookie) => ({
249
+ name: cookie.name,
250
+ value: cookie.value,
251
+ domain: cookie.domain,
252
+ path: cookie.path,
253
+ secure: cookie.secure === true,
254
+ httpOnly: cookie.httpOnly === true,
255
+ sameSite: cookie.sameSite || null,
256
+ expirationDate: cookie.expirationDate || null,
257
+ })),
258
+ };
259
+ }
224
260
  case COMMANDS.LAUNCH:
225
261
  await attach();
226
262
  return pageSnapshot({ screenshot: false });
@@ -357,8 +357,9 @@ class _LocalInstallWidgetState extends State<_LocalInstallWidget> {
357
357
  if (!mounted) return;
358
358
  setState(() {
359
359
  _phase = exit == 0 ? _LocalInstallPhase.done : _LocalInstallPhase.failed;
360
- if (exit != 0)
360
+ if (exit != 0) {
361
361
  _errorMsg = 'Installation failed (exit $exit). Check the log above.';
362
+ }
362
363
  });
363
364
  }
364
365
 
@@ -471,8 +472,9 @@ class _LocalInstallWidgetState extends State<_LocalInstallWidget> {
471
472
  final proc = _proc;
472
473
  proc?.kill(ProcessSignal.sigterm);
473
474
  await proc?.exitCode;
474
- if (mounted)
475
+ if (mounted) {
475
476
  setState(() => _phase = _LocalInstallPhase.ready);
477
+ }
476
478
  },
477
479
  child: const Text('Cancel'),
478
480
  ),
@@ -1607,7 +1609,11 @@ class _HomeViewState extends State<HomeView> {
1607
1609
  titleSpacing: 0,
1608
1610
  leadingWidth: 44,
1609
1611
  centerTitle: false,
1610
- title: Text(controller.selectedSection.navigationTitle),
1612
+ title: Text(
1613
+ controller.selectedSection.navigationTitle,
1614
+ maxLines: 1,
1615
+ overflow: TextOverflow.ellipsis,
1616
+ ),
1611
1617
  titleTextStyle: Theme.of(
1612
1618
  context,
1613
1619
  ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
@@ -182,6 +182,7 @@ class NeoAgentController extends ChangeNotifier {
182
182
  List<AiWidgetItem> widgets = const <AiWidgetItem>[];
183
183
  List<McpServerItem> mcpServers = const <McpServerItem>[];
184
184
  Map<String, dynamic> browserRuntime = const <String, dynamic>{};
185
+ Map<String, dynamic> socialReachStatus = const <String, dynamic>{};
185
186
  Map<String, dynamic> browserExtensionStatus = const <String, dynamic>{};
186
187
  List<Map<String, dynamic>> browserExtensionTokens =
187
188
  const <Map<String, dynamic>>[];
@@ -2316,6 +2317,9 @@ class NeoAgentController extends ChangeNotifier {
2316
2317
  final browserFuture = _backendClient
2317
2318
  .fetchBrowserStatus(backendUrl)
2318
2319
  .catchError((_) => const <String, dynamic>{});
2320
+ final socialReachFuture = _backendClient
2321
+ .fetchSocialReachStatus(backendUrl)
2322
+ .catchError((_) => const <String, dynamic>{});
2319
2323
  final browserExtensionFuture = _backendClient
2320
2324
  .fetchBrowserExtensionStatus(backendUrl)
2321
2325
  .catchError((_) => const <String, dynamic>{});
@@ -2371,6 +2375,7 @@ class NeoAgentController extends ChangeNotifier {
2371
2375
  final mcpResponse = await mcpFuture;
2372
2376
  final recordingsResponse = await recordingsFuture;
2373
2377
  final browserResponse = await browserFuture;
2378
+ final socialReachResponse = await socialReachFuture;
2374
2379
  final browserExtensionResponse = await browserExtensionFuture;
2375
2380
  final androidResponse = await androidFuture;
2376
2381
  final desktopResponse = await desktopFuture;
@@ -2468,6 +2473,7 @@ class NeoAgentController extends ChangeNotifier {
2468
2473
  RecordingSessionItem.fromJson,
2469
2474
  );
2470
2475
  browserRuntime = Map<String, dynamic>.from(browserResponse);
2476
+ socialReachStatus = Map<String, dynamic>.from(socialReachResponse);
2471
2477
  browserExtensionStatus = Map<String, dynamic>.from(
2472
2478
  browserExtensionResponse,
2473
2479
  );
@@ -5892,6 +5898,32 @@ class NeoAgentController extends ChangeNotifier {
5892
5898
  }
5893
5899
  }
5894
5900
 
5901
+ Future<Map<String, dynamic>> refreshSocialReachStatus() async {
5902
+ final response = await _backendClient.fetchSocialReachStatus(backendUrl);
5903
+ socialReachStatus = Map<String, dynamic>.from(response);
5904
+ notifyListeners();
5905
+ return socialReachStatus;
5906
+ }
5907
+
5908
+ Future<Map<String, dynamic>> importSocialReachCookies(String platform) async {
5909
+ final response = await _backendClient.importSocialReachCookies(
5910
+ backendUrl,
5911
+ platform,
5912
+ tokenId: selectedBrowserExtensionTokenId,
5913
+ );
5914
+ await refreshSocialReachStatus();
5915
+ return response;
5916
+ }
5917
+
5918
+ Future<Map<String, dynamic>> clearSocialReachCookies(String platform) async {
5919
+ final response = await _backendClient.clearSocialReachCookies(
5920
+ backendUrl,
5921
+ platform,
5922
+ );
5923
+ await refreshSocialReachStatus();
5924
+ return response;
5925
+ }
5926
+
5895
5927
  Future<void> disconnectMessagingPlatform(String platform) async {
5896
5928
  final busyKey = '$platform:disconnect';
5897
5929
  if (_busyMessagingPlatformKeys.contains(busyKey)) {
@@ -117,6 +117,22 @@ const _modelsSettingsSection = _SettingsSection('models', <String>[
117
117
  'smart selector',
118
118
  ]);
119
119
 
120
+ const _socialReachSettingsSection = _SettingsSection('social reach', <String>[
121
+ 'social',
122
+ 'reach',
123
+ 'web',
124
+ 'rss',
125
+ 'github',
126
+ 'youtube',
127
+ 'linkedin',
128
+ 'xueqiu',
129
+ 'twitter',
130
+ 'reddit',
131
+ 'instagram',
132
+ 'facebook',
133
+ 'cookies',
134
+ ]);
135
+
120
136
  const _voiceRecordingSettingsSection = _SettingsSection(
121
137
  'voice recording',
122
138
  <String>[
@@ -164,6 +180,7 @@ const _securitySettingsSection = _SettingsSection('security', <String>[
164
180
  const List<_SettingsSection> _settingsSearchSections = <_SettingsSection>[
165
181
  _overviewSettingsSection,
166
182
  _workspaceSettingsSection,
183
+ _socialReachSettingsSection,
167
184
  _modelsSettingsSection,
168
185
  _voiceRecordingSettingsSection,
169
186
  _desktopSettingsSection,
@@ -201,6 +218,9 @@ class _SettingsPanelState extends State<SettingsPanel> {
201
218
  Map<String, dynamic>? _extensionTestResult;
202
219
  bool _desktopTestRunning = false;
203
220
  Map<String, dynamic>? _desktopTestResult;
221
+ bool _socialReachRefreshing = false;
222
+ String? _socialReachBusyPlatform;
223
+ Map<String, dynamic>? _socialReachActionResult;
204
224
 
205
225
  @override
206
226
  void initState() {
@@ -399,6 +419,13 @@ class _SettingsPanelState extends State<SettingsPanel> {
399
419
  _buildWorkspaceSection(controller),
400
420
  const SizedBox(height: 16),
401
421
  ],
422
+ if (_matchesSettingsSection(
423
+ searchQuery,
424
+ _socialReachSettingsSection,
425
+ )) ...<Widget>[
426
+ _buildSocialReachSection(controller),
427
+ const SizedBox(height: 16),
428
+ ],
402
429
  if (_matchesSettingsSection(
403
430
  searchQuery,
404
431
  _modelsSettingsSection,
@@ -920,6 +947,278 @@ class _SettingsPanelState extends State<SettingsPanel> {
920
947
  );
921
948
  }
922
949
 
950
+ List<Map<String, dynamic>> _socialReachPlatforms(
951
+ NeoAgentController controller,
952
+ ) {
953
+ final raw = controller.socialReachStatus['platforms'];
954
+ if (raw is! List) return const <Map<String, dynamic>>[];
955
+ return raw
956
+ .whereType<Map>()
957
+ .map((item) => Map<String, dynamic>.from(item))
958
+ .toList();
959
+ }
960
+
961
+ Color _socialReachStatusColor(Map<String, dynamic> platform) {
962
+ final status = platform['status']?.toString().toLowerCase() ?? '';
963
+ if (platform['ready'] == true || status == 'ok') return _success;
964
+ if (status == 'warn') return _warning;
965
+ if (status == 'error') return _danger;
966
+ return _textSecondary;
967
+ }
968
+
969
+ Widget _buildSocialReachSection(NeoAgentController controller) {
970
+ final platforms = _socialReachPlatforms(controller);
971
+ final ready = platforms
972
+ .where((item) => item['ready'] == true || item['status'] == 'ok')
973
+ .length;
974
+ final unsupported = platforms
975
+ .where((item) => item['setupKind'] == 'unsupported_node_only')
976
+ .length;
977
+ return Card(
978
+ child: Padding(
979
+ padding: const EdgeInsets.all(20),
980
+ child: Column(
981
+ crossAxisAlignment: CrossAxisAlignment.start,
982
+ children: <Widget>[
983
+ Row(
984
+ children: <Widget>[
985
+ const Expanded(child: _SectionTitle('Social Reach')),
986
+ IconButton(
987
+ tooltip: 'Refresh',
988
+ onPressed: _socialReachRefreshing
989
+ ? null
990
+ : () async {
991
+ setState(() {
992
+ _socialReachRefreshing = true;
993
+ _socialReachActionResult = null;
994
+ });
995
+ try {
996
+ await controller.refreshSocialReachStatus();
997
+ } catch (e) {
998
+ if (mounted) {
999
+ setState(
1000
+ () => _socialReachActionResult =
1001
+ <String, dynamic>{'error': e.toString()},
1002
+ );
1003
+ }
1004
+ } finally {
1005
+ if (mounted) {
1006
+ setState(() => _socialReachRefreshing = false);
1007
+ }
1008
+ }
1009
+ },
1010
+ icon: _socialReachRefreshing
1011
+ ? const SizedBox.square(
1012
+ dimension: 18,
1013
+ child: CircularProgressIndicator(strokeWidth: 2),
1014
+ )
1015
+ : const Icon(Icons.refresh_rounded),
1016
+ ),
1017
+ ],
1018
+ ),
1019
+ const SizedBox(height: 10),
1020
+ Text(
1021
+ 'Node-native web and social content access for agents.',
1022
+ style: TextStyle(color: _textSecondary, height: 1.45),
1023
+ ),
1024
+ const SizedBox(height: 12),
1025
+ Wrap(
1026
+ spacing: 8,
1027
+ runSpacing: 8,
1028
+ children: <Widget>[
1029
+ _MetaPill(
1030
+ icon: Icons.check_circle_outline,
1031
+ label: '$ready ready',
1032
+ color: _success,
1033
+ ),
1034
+ _MetaPill(
1035
+ icon: Icons.code_outlined,
1036
+ label: 'Node-only mode',
1037
+ color: _info,
1038
+ ),
1039
+ _MetaPill(
1040
+ icon: Icons.block_outlined,
1041
+ label: '$unsupported unavailable',
1042
+ color: _warning,
1043
+ ),
1044
+ ],
1045
+ ),
1046
+ if (_socialReachActionResult != null) ...<Widget>[
1047
+ const SizedBox(height: 12),
1048
+ Container(
1049
+ width: double.infinity,
1050
+ padding: const EdgeInsets.all(10),
1051
+ decoration: BoxDecoration(
1052
+ color:
1053
+ (_socialReachActionResult!['error'] == null
1054
+ ? _success
1055
+ : _danger)
1056
+ .withValues(alpha: 0.10),
1057
+ borderRadius: BorderRadius.circular(8),
1058
+ border: Border.all(
1059
+ color:
1060
+ (_socialReachActionResult!['error'] == null
1061
+ ? _success
1062
+ : _danger)
1063
+ .withValues(alpha: 0.30),
1064
+ ),
1065
+ ),
1066
+ child: Text(
1067
+ _socialReachActionResult!['error']?.toString() ??
1068
+ 'Social Reach updated.',
1069
+ style: TextStyle(
1070
+ color: _socialReachActionResult!['error'] == null
1071
+ ? _success
1072
+ : _danger,
1073
+ ),
1074
+ ),
1075
+ ),
1076
+ ],
1077
+ const SizedBox(height: 16),
1078
+ if (platforms.isEmpty)
1079
+ Text(
1080
+ 'Status is not loaded yet.',
1081
+ style: TextStyle(color: _textSecondary),
1082
+ )
1083
+ else
1084
+ ...platforms.map(
1085
+ (platform) => Padding(
1086
+ padding: const EdgeInsets.only(bottom: 10),
1087
+ child: _buildSocialReachPlatformRow(controller, platform),
1088
+ ),
1089
+ ),
1090
+ ],
1091
+ ),
1092
+ ),
1093
+ );
1094
+ }
1095
+
1096
+ Widget _buildSocialReachPlatformRow(
1097
+ NeoAgentController controller,
1098
+ Map<String, dynamic> platform,
1099
+ ) {
1100
+ final id = platform['platform']?.toString() ?? '';
1101
+ final label = platform['label']?.toString() ?? id;
1102
+ final setupKind = platform['setupKind']?.toString() ?? '';
1103
+ final status = platform['status']?.toString() ?? 'off';
1104
+ final message = platform['message']?.toString() ?? '';
1105
+ final cookie = platform['cookie'] is Map
1106
+ ? Map<String, dynamic>.from(platform['cookie'] as Map)
1107
+ : const <String, dynamic>{};
1108
+ final busy = _socialReachBusyPlatform == id;
1109
+ final canImport = setupKind == 'cookies';
1110
+ return Container(
1111
+ padding: const EdgeInsets.all(14),
1112
+ decoration: BoxDecoration(
1113
+ color: _bgSecondary,
1114
+ borderRadius: BorderRadius.circular(8),
1115
+ border: Border.all(color: _border),
1116
+ ),
1117
+ child: Column(
1118
+ crossAxisAlignment: CrossAxisAlignment.start,
1119
+ children: <Widget>[
1120
+ Row(
1121
+ crossAxisAlignment: CrossAxisAlignment.start,
1122
+ children: <Widget>[
1123
+ Expanded(
1124
+ child: Text(
1125
+ label,
1126
+ style: TextStyle(
1127
+ color: _textPrimary,
1128
+ fontWeight: FontWeight.w800,
1129
+ ),
1130
+ ),
1131
+ ),
1132
+ _StatusPill(
1133
+ label: status,
1134
+ color: _socialReachStatusColor(platform),
1135
+ ),
1136
+ ],
1137
+ ),
1138
+ const SizedBox(height: 6),
1139
+ Text(message, style: TextStyle(color: _textSecondary, height: 1.35)),
1140
+ if (cookie.isNotEmpty) ...<Widget>[
1141
+ const SizedBox(height: 8),
1142
+ Text(
1143
+ cookie['configured'] == true
1144
+ ? '${cookie['count'] ?? 0} cookies imported'
1145
+ : 'Cookies not configured',
1146
+ style: TextStyle(color: _textSecondary, fontSize: 12),
1147
+ ),
1148
+ ],
1149
+ if (canImport) ...<Widget>[
1150
+ const SizedBox(height: 10),
1151
+ Wrap(
1152
+ spacing: 8,
1153
+ runSpacing: 8,
1154
+ children: <Widget>[
1155
+ FilledButton.icon(
1156
+ onPressed: busy
1157
+ ? null
1158
+ : () => _runSocialReachAction(
1159
+ controller,
1160
+ id,
1161
+ () => controller.importSocialReachCookies(id),
1162
+ ),
1163
+ icon: busy
1164
+ ? const SizedBox.square(
1165
+ dimension: 16,
1166
+ child: CircularProgressIndicator(
1167
+ strokeWidth: 2,
1168
+ color: Colors.white,
1169
+ ),
1170
+ )
1171
+ : const Icon(Icons.extension_outlined, size: 18),
1172
+ label: const Text('Import from extension'),
1173
+ ),
1174
+ OutlinedButton.icon(
1175
+ onPressed: busy
1176
+ ? null
1177
+ : () => _runSocialReachAction(
1178
+ controller,
1179
+ id,
1180
+ () => controller.clearSocialReachCookies(id),
1181
+ ),
1182
+ icon: const Icon(Icons.delete_outline, size: 18),
1183
+ label: const Text('Clear'),
1184
+ ),
1185
+ ],
1186
+ ),
1187
+ ],
1188
+ ],
1189
+ ),
1190
+ );
1191
+ }
1192
+
1193
+ Future<void> _runSocialReachAction(
1194
+ NeoAgentController controller,
1195
+ String platform,
1196
+ Future<Map<String, dynamic>> Function() action,
1197
+ ) async {
1198
+ setState(() {
1199
+ _socialReachBusyPlatform = platform;
1200
+ _socialReachActionResult = null;
1201
+ });
1202
+ try {
1203
+ final result = await action();
1204
+ if (mounted) {
1205
+ setState(() => _socialReachActionResult = result);
1206
+ }
1207
+ } catch (e) {
1208
+ if (mounted) {
1209
+ setState(
1210
+ () => _socialReachActionResult = <String, dynamic>{
1211
+ 'error': e.toString(),
1212
+ },
1213
+ );
1214
+ }
1215
+ } finally {
1216
+ if (mounted) {
1217
+ setState(() => _socialReachBusyPlatform = null);
1218
+ }
1219
+ }
1220
+ }
1221
+
923
1222
  Widget _buildModelsSection({
924
1223
  required BuildContext context,
925
1224
  required NeoAgentController controller,
@@ -1075,11 +1374,12 @@ class _SettingsPanelState extends State<SettingsPanel> {
1075
1374
  selectedIds: _enabledModels,
1076
1375
  ),
1077
1376
  );
1078
- if (result != null)
1377
+ if (result != null) {
1079
1378
  setState(() {
1080
1379
  _enabledModels = result;
1081
1380
  _hasUnsavedChanges = true;
1082
1381
  });
1382
+ }
1083
1383
  },
1084
1384
  ),
1085
1385
  ],
@@ -588,7 +588,7 @@ class _PageTitle extends StatelessWidget {
588
588
  Widget build(BuildContext context) {
589
589
  final compact = MediaQuery.sizeOf(context).width < 760;
590
590
  final titleStyle = compact
591
- ? _displayTitleStyle(26)
591
+ ? _displayTitleStyle(22)
592
592
  : _displayTitleStyle(32);
593
593
  final subtitleStyle = TextStyle(
594
594
  color: _textSecondary,
@@ -603,7 +603,12 @@ class _PageTitle extends StatelessWidget {
603
603
  children: <Widget>[
604
604
  Text('CONTROL SURFACE', style: _sectionEyebrowStyle()),
605
605
  const SizedBox(height: 6),
606
- Text(title, style: titleStyle),
606
+ Text(
607
+ title,
608
+ maxLines: 1,
609
+ overflow: TextOverflow.ellipsis,
610
+ style: titleStyle,
611
+ ),
607
612
  const SizedBox(height: 8),
608
613
  ConstrainedBox(
609
614
  constraints: const BoxConstraints(maxWidth: 720),
@@ -51,6 +51,67 @@ class _TimelinePanelState extends State<TimelinePanel> {
51
51
  return items.first;
52
52
  }
53
53
 
54
+ Future<void> _showMobileEventDetails(
55
+ List<TimelineEventItem> items,
56
+ TimelineEventItem initialEvent,
57
+ ) async {
58
+ _selectEvent(initialEvent);
59
+ var selectedIndex = items.indexWhere((item) => item.id == initialEvent.id);
60
+ if (selectedIndex < 0) {
61
+ selectedIndex = 0;
62
+ }
63
+
64
+ await showModalBottomSheet<void>(
65
+ context: context,
66
+ isScrollControlled: true,
67
+ useSafeArea: true,
68
+ backgroundColor: Colors.transparent,
69
+ builder: (context) {
70
+ return StatefulBuilder(
71
+ builder: (context, setSheetState) {
72
+ final selectedEvent = items[selectedIndex];
73
+ void selectOffset(int offset) {
74
+ final nextIndex = (selectedIndex + offset).clamp(
75
+ 0,
76
+ items.length - 1,
77
+ );
78
+ if (nextIndex == selectedIndex) {
79
+ return;
80
+ }
81
+ setSheetState(() {
82
+ selectedIndex = nextIndex;
83
+ });
84
+ _selectEvent(items[nextIndex]);
85
+ }
86
+
87
+ return FractionallySizedBox(
88
+ heightFactor: 0.9,
89
+ child: Padding(
90
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
91
+ child: _TimelineDetailPane(
92
+ items: items,
93
+ selectedEvent: selectedEvent,
94
+ selectedIndex: selectedIndex,
95
+ onSelectPrevious: selectedIndex > 0
96
+ ? () => selectOffset(-1)
97
+ : null,
98
+ onSelectNext: selectedIndex < items.length - 1
99
+ ? () => selectOffset(1)
100
+ : null,
101
+ onOpenRun: selectedEvent.runId.isNotEmpty
102
+ ? () => unawaited(
103
+ widget.controller.openRunDetails(selectedEvent.runId),
104
+ )
105
+ : null,
106
+ ),
107
+ ),
108
+ );
109
+ },
110
+ );
111
+ },
112
+ );
113
+ }
114
+
54
115
  @override
55
116
  Widget build(BuildContext context) {
56
117
  final items = _sortedTimelineEvents(widget.controller.timelineItems);
@@ -87,10 +148,15 @@ class _TimelinePanelState extends State<TimelinePanel> {
87
148
  : LayoutBuilder(
88
149
  builder: (context, constraints) {
89
150
  final isWide = constraints.maxWidth >= 1180;
151
+ final isCompact = constraints.maxWidth < 760;
90
152
  final feedPane = _TimelineFeedPane(
91
153
  groups: groups,
92
154
  selectedEventId: selectedEvent?.id,
93
- onSelectEvent: _selectEvent,
155
+ onSelectEvent: isCompact
156
+ ? (item) => unawaited(
157
+ _showMobileEventDetails(items, item),
158
+ )
159
+ : _selectEvent,
94
160
  );
95
161
  final detailPane = _TimelineDetailPane(
96
162
  items: items,
@@ -126,6 +192,10 @@ class _TimelinePanelState extends State<TimelinePanel> {
126
192
  );
127
193
  }
128
194
 
195
+ if (isCompact) {
196
+ return feedPane;
197
+ }
198
+
129
199
  return Column(
130
200
  crossAxisAlignment: CrossAxisAlignment.stretch,
131
201
  children: <Widget>[
@@ -156,13 +226,14 @@ class _TimelineHeroHeader extends StatelessWidget {
156
226
 
157
227
  @override
158
228
  Widget build(BuildContext context) {
229
+ final compact = MediaQuery.sizeOf(context).width < 760;
159
230
  final focusedDay =
160
231
  selectedEvent?.occurredAt ??
161
232
  (items.isEmpty ? null : items.first.occurredAt);
162
233
 
163
234
  return Container(
164
235
  width: double.infinity,
165
- padding: const EdgeInsets.all(22),
236
+ padding: EdgeInsets.all(compact ? 16 : 22),
166
237
  decoration: BoxDecoration(
167
238
  gradient: LinearGradient(
168
239
  colors: <Color>[
@@ -172,7 +243,7 @@ class _TimelineHeroHeader extends StatelessWidget {
172
243
  begin: Alignment.topLeft,
173
244
  end: Alignment.bottomRight,
174
245
  ),
175
- borderRadius: BorderRadius.circular(28),
246
+ borderRadius: BorderRadius.circular(compact ? 20 : 28),
176
247
  border: Border.all(color: _borderLight),
177
248
  boxShadow: <BoxShadow>[
178
249
  BoxShadow(
@@ -195,27 +266,27 @@ class _TimelineHeroHeader extends StatelessWidget {
195
266
  'ACTIVITY FEED',
196
267
  style: TextStyle(
197
268
  color: _accentHover,
198
- fontSize: 13,
269
+ fontSize: compact ? 11 : 13,
199
270
  fontWeight: FontWeight.w700,
200
- letterSpacing: 4.2,
271
+ letterSpacing: compact ? 2.4 : 4.2,
201
272
  ),
202
273
  ),
203
- const SizedBox(height: 14),
204
- const Text(
274
+ SizedBox(height: compact ? 8 : 14),
275
+ Text(
205
276
  'Timeline',
206
277
  style: TextStyle(
207
- fontSize: 40,
278
+ fontSize: compact ? 28 : 40,
208
279
  fontWeight: FontWeight.w800,
209
280
  height: 1,
210
281
  ),
211
282
  ),
212
- const SizedBox(height: 14),
283
+ SizedBox(height: compact ? 8 : 14),
213
284
  Text(
214
285
  'Emails, AI actions, recordings, tasks and run activity in one chronological feed.',
215
286
  style: TextStyle(
216
287
  color: _textSecondary,
217
- fontSize: 16.5,
218
- height: 1.35,
288
+ fontSize: compact ? 13.5 : 16.5,
289
+ height: compact ? 1.28 : 1.35,
219
290
  ),
220
291
  ),
221
292
  ],