neoagent 2.3.1-beta.86 → 2.3.1-beta.88

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 (52) hide show
  1. package/docs/capabilities.md +2 -0
  2. package/flutter_app/android/app/src/main/AndroidManifest.xml +14 -0
  3. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +84 -0
  4. package/flutter_app/lib/features/notifications/notification_interceptor.dart +11 -45
  5. package/flutter_app/lib/main.dart +1 -0
  6. package/flutter_app/lib/main_app_shell.dart +23 -14
  7. package/flutter_app/lib/main_chat.dart +267 -7
  8. package/flutter_app/lib/main_controller.dart +151 -13
  9. package/flutter_app/lib/main_models.dart +69 -0
  10. package/flutter_app/lib/main_operations.dart +248 -0
  11. package/flutter_app/lib/main_runtime.dart +11 -2
  12. package/flutter_app/lib/main_settings.dart +182 -183
  13. package/flutter_app/lib/main_shared.dart +99 -16
  14. package/flutter_app/lib/src/app_launch_bridge.dart +39 -10
  15. package/flutter_app/lib/src/backend_client.dart +28 -0
  16. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +2 -0
  17. package/flutter_app/pubspec.lock +24 -0
  18. package/flutter_app/pubspec.yaml +1 -0
  19. package/package.json +1 -1
  20. package/server/db/database.js +42 -4
  21. package/server/guest_agent.js +8 -1
  22. package/server/http/routes.js +1 -0
  23. package/server/public/.last_build_id +1 -1
  24. package/server/public/assets/NOTICES +70 -44
  25. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  26. package/server/public/flutter_bootstrap.js +1 -1
  27. package/server/public/main.dart.js +70947 -70038
  28. package/server/routes/browser.js +1 -1
  29. package/server/routes/memory.js +90 -0
  30. package/server/routes/social_video.js +66 -0
  31. package/server/services/ai/systemPrompt.js +1 -0
  32. package/server/services/ai/toolResult.js +20 -0
  33. package/server/services/ai/tools.js +30 -0
  34. package/server/services/android/android_bootstrap_worker.js +1 -0
  35. package/server/services/android/controller.js +244 -76
  36. package/server/services/browser/controller.js +24 -8
  37. package/server/services/manager.js +15 -0
  38. package/server/services/memory/llm_transfer.js +217 -0
  39. package/server/services/runtime/backends/local-vm.js +29 -5
  40. package/server/services/social_video/adapters/base.js +26 -0
  41. package/server/services/social_video/adapters/index.js +27 -0
  42. package/server/services/social_video/adapters/instagram.js +17 -0
  43. package/server/services/social_video/adapters/tiktok.js +17 -0
  44. package/server/services/social_video/adapters/x.js +17 -0
  45. package/server/services/social_video/adapters/youtube.js +17 -0
  46. package/server/services/social_video/captions.js +187 -0
  47. package/server/services/social_video/frame.js +42 -0
  48. package/server/services/social_video/index.js +7 -0
  49. package/server/services/social_video/metadata.js +63 -0
  50. package/server/services/social_video/result.js +63 -0
  51. package/server/services/social_video/service.js +650 -0
  52. package/server/services/social_video/url.js +83 -0
@@ -33,6 +33,7 @@ Recording sessions support:
33
33
  - Retry transcription and delete transcript segment actions.
34
34
  - Full session deletion with storage cleanup.
35
35
  - Agent tools for listing, opening, and searching transcripts: `recordings_list`, `recordings_get`, and `recordings_search`.
36
+ - A keyless social video extractor for public YouTube, TikTok, Instagram, and X URLs through `social_video_extract`, with metadata-first fetch, captions-first transcript strategy, STT fallback, and representative frame extraction.
36
37
 
37
38
  Transcription uses Deepgram when `DEEPGRAM_API_KEY` is configured. The default speech model is `nova-3`, and the default language mode is `multi`. When `auto_recording_insights` is enabled in AI settings, NeoAgent can generate structured recording insights such as a summary, action items, and events.
38
39
 
@@ -102,6 +103,7 @@ NeoAgent's agent tool surface includes more than basic chat:
102
103
  | Output | Generate markdown tables and Mermaid graphs |
103
104
  | Images | Generate images with Grok and analyze local image files with a vision-capable model |
104
105
  | Recordings | List, inspect, and search recording transcripts |
106
+ | Social video | Extract title, description, transcript, and a representative frame from public social video URLs |
105
107
  | Health | Read synced mobile health metrics |
106
108
 
107
109
  Generated binary or text artifacts can be promoted into user-scoped artifact storage under `~/.neoagent/data/artifacts` and served through authenticated `/api/artifacts/:id/content` URLs.
@@ -56,6 +56,20 @@
56
56
  <action android:name="android.intent.action.MAIN"/>
57
57
  <category android:name="android.intent.category.LAUNCHER"/>
58
58
  </intent-filter>
59
+ <intent-filter>
60
+ <action android:name="android.intent.action.SEND" />
61
+ <category android:name="android.intent.category.DEFAULT" />
62
+ <data android:mimeType="text/*" />
63
+ <data android:mimeType="video/*" />
64
+ <data android:mimeType="image/*" />
65
+ <data android:mimeType="audio/*" />
66
+ <data android:mimeType="application/*" />
67
+ </intent-filter>
68
+ <intent-filter>
69
+ <action android:name="android.intent.action.SEND_MULTIPLE" />
70
+ <category android:name="android.intent.category.DEFAULT" />
71
+ <data android:mimeType="*/*" />
72
+ </intent-filter>
59
73
  </activity>
60
74
  <provider
61
75
  android:name="androidx.core.content.FileProvider"
@@ -8,6 +8,7 @@ import android.net.Uri
8
8
  import android.os.BatteryManager
9
9
  import android.os.Build
10
10
  import android.provider.Settings
11
+ import android.provider.OpenableColumns
11
12
  import android.view.KeyEvent
12
13
  import androidx.activity.result.ActivityResultLauncher
13
14
  import androidx.activity.result.contract.ActivityResultContracts
@@ -55,6 +56,7 @@ class MainActivity : FlutterFragmentActivity() {
55
56
  private var widgetEventSink: EventChannel.EventSink? = null
56
57
  private var appLaunchEventSink: EventChannel.EventSink? = null
57
58
  private var pendingAppLaunchAction: String? = null
59
+ private var pendingSharePayload: Map<String, Any?>? = null
58
60
 
59
61
  override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
60
62
  super.configureFlutterEngine(flutterEngine)
@@ -590,6 +592,7 @@ class MainActivity : FlutterFragmentActivity() {
590
592
 
591
593
  captureWidgetIntent(intent)
592
594
  captureAppLaunchIntent(intent)
595
+ captureShareIntent(intent)
593
596
  }
594
597
 
595
598
  override fun onNewIntent(intent: Intent) {
@@ -597,6 +600,7 @@ class MainActivity : FlutterFragmentActivity() {
597
600
  setIntent(intent)
598
601
  captureWidgetIntent(intent)
599
602
  captureAppLaunchIntent(intent)
603
+ captureShareIntent(intent)
600
604
  }
601
605
 
602
606
  override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
@@ -723,11 +727,91 @@ class MainActivity : FlutterFragmentActivity() {
723
727
 
724
728
  private fun emitPendingAppLaunchIntent() {
725
729
  val sink = appLaunchEventSink ?: return
730
+ val sharePayload = pendingSharePayload
731
+ if (sharePayload != null) {
732
+ pendingSharePayload = null
733
+ sink.success(sharePayload)
734
+ return
735
+ }
726
736
  val action = pendingAppLaunchAction ?: return
727
737
  pendingAppLaunchAction = null
728
738
  sink.success(mapOf("action" to action))
729
739
  }
730
740
 
741
+ private fun captureShareIntent(intent: Intent?) {
742
+ if (intent == null) {
743
+ return
744
+ }
745
+ if (intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_SEND_MULTIPLE) {
746
+ return
747
+ }
748
+
749
+ val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim().orEmpty()
750
+ val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.trim().orEmpty()
751
+ val fileUris = mutableListOf<Uri>()
752
+
753
+ @Suppress("DEPRECATION")
754
+ val single = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
755
+ if (single != null) {
756
+ fileUris.add(single)
757
+ }
758
+ @Suppress("DEPRECATION")
759
+ val multiple = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
760
+ if (multiple != null) {
761
+ fileUris.addAll(multiple)
762
+ }
763
+ val clip = intent.clipData
764
+ if (clip != null) {
765
+ for (index in 0 until clip.itemCount) {
766
+ val uri = clip.getItemAt(index)?.uri ?: continue
767
+ fileUris.add(uri)
768
+ }
769
+ }
770
+
771
+ val uniqueUris = linkedSetOf<String>()
772
+ val files = mutableListOf<Map<String, Any?>>()
773
+ for (uri in fileUris) {
774
+ val key = uri.toString()
775
+ if (!uniqueUris.add(key)) continue
776
+ files.add(buildSharedFileDescriptor(uri))
777
+ }
778
+
779
+ pendingSharePayload = mapOf(
780
+ "action" to "share_to_chat",
781
+ "text" to sharedText,
782
+ "subject" to subject,
783
+ "files" to files,
784
+ )
785
+ emitPendingAppLaunchIntent()
786
+ }
787
+
788
+ private fun buildSharedFileDescriptor(uri: Uri): Map<String, Any?> {
789
+ var name: String? = null
790
+ var sizeBytes: Long? = null
791
+ val type = contentResolver.getType(uri)?.trim().orEmpty()
792
+ contentResolver.query(uri, null, null, null, null)?.use { cursor ->
793
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
794
+ val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
795
+ if (cursor.moveToFirst()) {
796
+ if (nameIndex >= 0) {
797
+ name = cursor.getString(nameIndex)?.trim()
798
+ }
799
+ if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
800
+ sizeBytes = cursor.getLong(sizeIndex)
801
+ }
802
+ }
803
+ }
804
+ val fallbackName = uri.lastPathSegment?.substringAfterLast('/')?.trim().orEmpty()
805
+ val resolvedName = (name ?: fallbackName).ifBlank { "Attachment" }
806
+ return mapOf(
807
+ "uri" to uri.toString(),
808
+ "name" to resolvedName,
809
+ "mimeType" to if (type.isBlank()) "application/octet-stream" else type,
810
+ "sizeBytes" to sizeBytes,
811
+ "source" to "android_share_intent",
812
+ )
813
+ }
814
+
731
815
  private fun buildVolumeState(audioManager: AudioManager): Map<String, Any> {
732
816
  val minVolume =
733
817
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@@ -5,7 +5,8 @@ import 'package:notification_listener_service/notification_listener_service.dart
5
5
  import 'package:notification_listener_service/notification_event.dart';
6
6
 
7
7
  class NotificationInterceptor {
8
- static final NotificationInterceptor _instance = NotificationInterceptor._internal();
8
+ static final NotificationInterceptor _instance =
9
+ NotificationInterceptor._internal();
9
10
  factory NotificationInterceptor() => _instance;
10
11
  NotificationInterceptor._internal();
11
12
 
@@ -13,59 +14,27 @@ class NotificationInterceptor {
13
14
  String _backendUrl = '';
14
15
  String _token = '';
15
16
 
16
- Future<void> initialize(BuildContext context, String backendUrl, String token) async {
17
+ Future<void> initialize(String backendUrl, String token) async {
17
18
  _backendUrl = backendUrl;
18
19
  _token = token;
19
20
 
20
- bool isGranted = await NotificationListenerService.isPermissionGranted();
21
- if (!isGranted) {
22
- bool userAgreed = await _showPermissionRationale(context);
23
- if (userAgreed) {
24
- await NotificationListenerService.requestPermission();
25
- }
26
- }
27
-
28
- isGranted = await NotificationListenerService.isPermissionGranted();
21
+ final isGranted = await NotificationListenerService.isPermissionGranted();
29
22
  if (isGranted && !_isListening) {
30
23
  _startListening();
31
24
  }
32
25
  }
33
26
 
34
- Future<bool> _showPermissionRationale(BuildContext context) async {
35
- return await showDialog<bool>(
36
- context: context,
37
- builder: (context) => AlertDialog(
38
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
39
- title: const Text('Notification Access Needed'),
40
- content: const Text(
41
- 'NeoAgent can read your incoming notifications (like Calendar alarms or urgent messages) and proactively act on them.\n\n'
42
- 'For example, if you receive a reminder, NeoAgent can automatically prepare relevant context for you.',
43
- ),
44
- actions: [
45
- TextButton(
46
- onPressed: () => Navigator.of(context).pop(false),
47
- child: const Text('Not Now'),
48
- ),
49
- ElevatedButton(
50
- style: ElevatedButton.styleFrom(
51
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
52
- ),
53
- onPressed: () => Navigator.of(context).pop(true),
54
- child: const Text('Allow Access'),
55
- ),
56
- ],
57
- ),
58
- ) ?? false;
59
- }
60
-
61
27
  void _startListening() {
62
28
  _isListening = true;
63
- NotificationListenerService.notificationsStream.listen((ServiceNotificationEvent event) {
29
+ NotificationListenerService.notificationsStream.listen((
30
+ ServiceNotificationEvent event,
31
+ ) {
64
32
  // Filter out noisy system notifications or ongoing foreground services
65
- if (event.packageName == null || event.packageName!.contains('android.system')) {
33
+ if (event.packageName == null ||
34
+ event.packageName!.contains('android.system')) {
66
35
  return;
67
36
  }
68
-
37
+
69
38
  // We only want to intercept newly posted notifications, not removed ones
70
39
  if (event.hasRemoved == true) return;
71
40
 
@@ -79,10 +48,7 @@ class NotificationInterceptor {
79
48
  try {
80
49
  await http.post(
81
50
  Uri.parse('$_backendUrl/api/triggers/notification'),
82
- headers: {
83
- 'Content-Type': 'application/json',
84
- 'Cookie': _token,
85
- },
51
+ headers: {'Content-Type': 'application/json', 'Cookie': _token},
86
52
  body: jsonEncode({
87
53
  'app_package': event.packageName ?? 'unknown',
88
54
  'title': event.title ?? '',
@@ -11,6 +11,7 @@ import 'package:flutter/gestures.dart';
11
11
  import 'package:flutter/material.dart';
12
12
  import 'package:flutter/services.dart';
13
13
  import 'package:flutter_markdown/flutter_markdown.dart';
14
+ import 'package:file_picker/file_picker.dart';
14
15
  import 'package:flutter_secure_storage/flutter_secure_storage.dart';
15
16
  import 'package:google_fonts/google_fonts.dart';
16
17
  import 'package:image/image.dart' as img;
@@ -13,17 +13,23 @@ class SplashView extends StatelessWidget {
13
13
  colors: <Color>[_accent, _bgSecondary, _bgPrimary],
14
14
  ),
15
15
  ),
16
- child: const Scaffold(
16
+ child: Scaffold(
17
17
  backgroundColor: Colors.transparent,
18
18
  body: Center(
19
19
  child: Column(
20
20
  mainAxisSize: MainAxisSize.min,
21
21
  children: <Widget>[
22
22
  _BrandLockup(logoSize: 52),
23
- SizedBox(height: 18),
24
- CircularProgressIndicator(),
25
- SizedBox(height: 16),
26
- Text('Loading NeoOS'),
23
+ const SizedBox(height: 18),
24
+ SizedBox(
25
+ width: 180,
26
+ child: ClipRRect(
27
+ borderRadius: BorderRadius.circular(999),
28
+ child: const LinearProgressIndicator(minHeight: 4),
29
+ ),
30
+ ),
31
+ const SizedBox(height: 14),
32
+ const Text('Loading NeoOS'),
27
33
  ],
28
34
  ),
29
35
  ),
@@ -1090,11 +1096,7 @@ class _HomeViewState extends State<HomeView> {
1090
1096
  });
1091
1097
 
1092
1098
  if (Platform.isAndroid) {
1093
- NotificationInterceptor().initialize(
1094
- context,
1095
- backendUrl,
1096
- sessionCookie,
1097
- );
1099
+ NotificationInterceptor().initialize(backendUrl, sessionCookie);
1098
1100
  }
1099
1101
  }
1100
1102
  }
@@ -1242,12 +1244,19 @@ class _HomeViewState extends State<HomeView> {
1242
1244
  onToggleGroup: _toggleSidebarGroup,
1243
1245
  ),
1244
1246
  appBar: AppBar(
1247
+ toolbarHeight: 48,
1248
+ titleSpacing: 0,
1249
+ leadingWidth: 44,
1250
+ centerTitle: false,
1245
1251
  title: Text(controller.selectedSection.navigationTitle),
1252
+ titleTextStyle: Theme.of(
1253
+ context,
1254
+ ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
1246
1255
  elevation: 0,
1247
1256
  ),
1248
1257
  body: SafeArea(
1249
1258
  child: Padding(
1250
- padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
1259
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
1251
1260
  child: _GlassSurface(
1252
1261
  borderRadius: BorderRadius.circular(26),
1253
1262
  blurSigma: 24,
@@ -2014,7 +2023,7 @@ class _MobileDrawer extends StatelessWidget {
2014
2023
  child: Column(
2015
2024
  children: <Widget>[
2016
2025
  Padding(
2017
- padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
2026
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
2018
2027
  child: Column(
2019
2028
  crossAxisAlignment: CrossAxisAlignment.start,
2020
2029
  children: <Widget>[
@@ -2032,7 +2041,7 @@ class _MobileDrawer extends StatelessWidget {
2032
2041
  ],
2033
2042
  ),
2034
2043
  if (controller.agentProfiles.isNotEmpty) ...<Widget>[
2035
- const SizedBox(height: 12),
2044
+ const SizedBox(height: 10),
2036
2045
  _AgentSwitcher(
2037
2046
  controller: controller,
2038
2047
  onChanged: () => Navigator.of(context).pop(),
@@ -2056,7 +2065,7 @@ class _MobileDrawer extends StatelessWidget {
2056
2065
  ),
2057
2066
  ),
2058
2067
  Padding(
2059
- padding: const EdgeInsets.all(8),
2068
+ padding: const EdgeInsets.fromLTRB(8, 6, 8, 8),
2060
2069
  child: Row(
2061
2070
  children: <Widget>[
2062
2071
  const Spacer(),
@@ -12,6 +12,9 @@ class ChatPanel extends StatefulWidget {
12
12
  class _ChatPanelState extends State<ChatPanel> {
13
13
  late final TextEditingController _composerController;
14
14
  final ScrollController _scrollController = ScrollController();
15
+ List<SharedChatAttachment> _pendingSharedAttachments =
16
+ const <SharedChatAttachment>[];
17
+ String? _appliedSharedPayloadSignature;
15
18
  int _lastMessageCount = 0;
16
19
  int _lastToolCount = 0;
17
20
  String _lastStream = '';
@@ -25,6 +28,17 @@ class _ChatPanelState extends State<ChatPanel> {
25
28
  _consumeQueuedDraft();
26
29
  }
27
30
 
31
+ @override
32
+ void didUpdateWidget(covariant ChatPanel oldWidget) {
33
+ super.didUpdateWidget(oldWidget);
34
+ if (oldWidget.controller != widget.controller) {
35
+ oldWidget.controller.removeListener(_consumeQueuedDraft);
36
+ widget.controller.addListener(_consumeQueuedDraft);
37
+ _appliedSharedPayloadSignature = null;
38
+ _consumeQueuedDraft();
39
+ }
40
+ }
41
+
28
42
  @override
29
43
  void dispose() {
30
44
  widget.controller.removeListener(_consumeQueuedDraft);
@@ -34,13 +48,114 @@ class _ChatPanelState extends State<ChatPanel> {
34
48
  }
35
49
 
36
50
  void _consumeQueuedDraft() {
37
- final draft = widget.controller.takePendingChatDraft();
38
- if (draft == null || draft.isEmpty) {
51
+ final draft = widget.controller.peekPendingChatDraft();
52
+ final attachments = widget.controller.peekPendingSharedChatAttachments();
53
+ final signature = _sharedPayloadSignature(draft, attachments);
54
+ if (draft == null && attachments.isEmpty) {
55
+ return;
56
+ }
57
+ if (_appliedSharedPayloadSignature == signature) {
58
+ return;
59
+ }
60
+ _appliedSharedPayloadSignature = signature;
61
+ if (!mounted) {
62
+ return;
63
+ }
64
+ setState(() {
65
+ if ((draft ?? '').isNotEmpty && _composerController.text.trim().isEmpty) {
66
+ _composerController
67
+ ..text = draft!
68
+ ..selection = TextSelection.collapsed(offset: draft.length);
69
+ }
70
+ _pendingSharedAttachments = attachments;
71
+ });
72
+ }
73
+
74
+ String _sharedPayloadSignature(
75
+ String? draft,
76
+ List<SharedChatAttachment> attachments,
77
+ ) {
78
+ final attachmentSignature = attachments
79
+ .map((item) => '${item.uri}|${item.name}|${item.mimeType}')
80
+ .join('::');
81
+ return '${draft ?? ''}::$attachmentSignature';
82
+ }
83
+
84
+ void _clearSharedPayload() {
85
+ widget.controller.clearPendingSharedChatPayload();
86
+ _appliedSharedPayloadSignature = null;
87
+ if (!mounted) {
88
+ return;
89
+ }
90
+ setState(() {
91
+ _pendingSharedAttachments = const <SharedChatAttachment>[];
92
+ });
93
+ }
94
+
95
+ String _mimeTypeForFileName(String fileName) {
96
+ final ext = fileName.split('.').last.toLowerCase();
97
+ switch (ext) {
98
+ case 'jpg':
99
+ case 'jpeg':
100
+ return 'image/jpeg';
101
+ case 'png':
102
+ return 'image/png';
103
+ case 'webp':
104
+ return 'image/webp';
105
+ case 'gif':
106
+ return 'image/gif';
107
+ case 'mp4':
108
+ return 'video/mp4';
109
+ case 'mov':
110
+ return 'video/quicktime';
111
+ case 'm4v':
112
+ return 'video/x-m4v';
113
+ case 'mp3':
114
+ return 'audio/mpeg';
115
+ case 'm4a':
116
+ return 'audio/mp4';
117
+ case 'wav':
118
+ return 'audio/wav';
119
+ case 'pdf':
120
+ return 'application/pdf';
121
+ case 'txt':
122
+ return 'text/plain';
123
+ default:
124
+ return 'application/octet-stream';
125
+ }
126
+ }
127
+
128
+ Future<void> _attachFiles() async {
129
+ final result = await FilePicker.platform.pickFiles(
130
+ allowMultiple: true,
131
+ withData: false,
132
+ type: FileType.any,
133
+ );
134
+ if (!mounted || result == null || result.files.isEmpty) {
39
135
  return;
40
136
  }
41
- _composerController
42
- ..text = draft
43
- ..selection = TextSelection.collapsed(offset: draft.length);
137
+ final attachments = result.files
138
+ .where((file) => file.path?.trim().isNotEmpty == true)
139
+ .map(
140
+ (file) => SharedChatAttachment(
141
+ uri: file.path!,
142
+ name: file.name,
143
+ mimeType: _mimeTypeForFileName(file.name),
144
+ sizeBytes: file.size,
145
+ source: 'file_picker',
146
+ ),
147
+ )
148
+ .where((item) => item.isValid)
149
+ .toList(growable: false);
150
+ if (attachments.isEmpty) {
151
+ return;
152
+ }
153
+ setState(() {
154
+ _pendingSharedAttachments = <SharedChatAttachment>[
155
+ ..._pendingSharedAttachments,
156
+ ...attachments,
157
+ ];
158
+ });
44
159
  }
45
160
 
46
161
  void _scrollToBottom() {
@@ -151,6 +266,29 @@ class _ChatPanelState extends State<ChatPanel> {
151
266
  ),
152
267
  child: Column(
153
268
  children: <Widget>[
269
+ if (_pendingSharedAttachments.isNotEmpty)
270
+ Padding(
271
+ padding: const EdgeInsets.only(bottom: 10),
272
+ child: _SharedAttachmentTray(
273
+ attachments: _pendingSharedAttachments,
274
+ onRemoveAt: (index) {
275
+ setState(() {
276
+ _pendingSharedAttachments = _pendingSharedAttachments
277
+ .asMap()
278
+ .entries
279
+ .where((entry) => entry.key != index)
280
+ .map((entry) => entry.value)
281
+ .toList(growable: false);
282
+ });
283
+ if (_pendingSharedAttachments.isEmpty) {
284
+ _clearSharedPayload();
285
+ }
286
+ },
287
+ onClear: () {
288
+ _clearSharedPayload();
289
+ },
290
+ ),
291
+ ),
154
292
  Container(
155
293
  padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
156
294
  decoration: BoxDecoration(
@@ -179,6 +317,13 @@ class _ChatPanelState extends State<ChatPanel> {
179
317
  ),
180
318
  ),
181
319
  const SizedBox(width: 8),
320
+ IconButton(
321
+ tooltip: 'Attach files',
322
+ onPressed: _attachFiles,
323
+ icon: const Icon(Icons.attach_file_rounded),
324
+ color: _textSecondary,
325
+ ),
326
+ const SizedBox(width: 2),
182
327
  FilledButton(
183
328
  onPressed: () => controller.setSelectedSection(
184
329
  AppSection.voiceAssistant,
@@ -199,7 +344,8 @@ class _ChatPanelState extends State<ChatPanel> {
199
344
  ? null
200
345
  : () async {
201
346
  final task = _composerController.text;
202
- if (task.trim().isEmpty ||
347
+ if ((task.trim().isEmpty &&
348
+ _pendingSharedAttachments.isEmpty) ||
203
349
  _isSendingChatMessage) {
204
350
  return;
205
351
  }
@@ -207,8 +353,14 @@ class _ChatPanelState extends State<ChatPanel> {
207
353
  _isSendingChatMessage = true;
208
354
  });
209
355
  _composerController.clear();
356
+ final outgoingAttachments =
357
+ _pendingSharedAttachments;
358
+ _clearSharedPayload();
210
359
  try {
211
- await controller.sendMessage(task);
360
+ await controller.sendMessage(
361
+ task,
362
+ sharedAttachments: outgoingAttachments,
363
+ );
212
364
  } finally {
213
365
  if (mounted) {
214
366
  setState(() {
@@ -268,6 +420,114 @@ class _ChatPanelState extends State<ChatPanel> {
268
420
  }
269
421
  }
270
422
 
423
+ class _SharedAttachmentTray extends StatelessWidget {
424
+ const _SharedAttachmentTray({
425
+ required this.attachments,
426
+ required this.onRemoveAt,
427
+ required this.onClear,
428
+ });
429
+
430
+ final List<SharedChatAttachment> attachments;
431
+ final void Function(int index) onRemoveAt;
432
+ final VoidCallback onClear;
433
+
434
+ IconData _iconForMime(String mime) {
435
+ final normalized = mime.toLowerCase();
436
+ if (normalized.startsWith('image/')) return Icons.image_outlined;
437
+ if (normalized.startsWith('video/')) return Icons.videocam_outlined;
438
+ if (normalized.startsWith('audio/')) return Icons.audiotrack_outlined;
439
+ if (normalized.contains('pdf')) return Icons.picture_as_pdf_outlined;
440
+ return Icons.attach_file_rounded;
441
+ }
442
+
443
+ @override
444
+ Widget build(BuildContext context) {
445
+ return Container(
446
+ width: double.infinity,
447
+ padding: const EdgeInsets.all(10),
448
+ decoration: BoxDecoration(
449
+ color: _bgSecondary,
450
+ borderRadius: BorderRadius.circular(12),
451
+ border: Border.all(color: _borderLight),
452
+ ),
453
+ child: Column(
454
+ crossAxisAlignment: CrossAxisAlignment.start,
455
+ children: <Widget>[
456
+ Row(
457
+ children: <Widget>[
458
+ Icon(Icons.forward_to_inbox_outlined, size: 15, color: _info),
459
+ const SizedBox(width: 6),
460
+ Expanded(
461
+ child: Text(
462
+ 'Shared from another app',
463
+ style: TextStyle(
464
+ color: _textPrimary,
465
+ fontSize: 12,
466
+ fontWeight: FontWeight.w700,
467
+ ),
468
+ ),
469
+ ),
470
+ TextButton(onPressed: onClear, child: const Text('Clear')),
471
+ ],
472
+ ),
473
+ const SizedBox(height: 6),
474
+ Wrap(
475
+ spacing: 8,
476
+ runSpacing: 8,
477
+ children: attachments
478
+ .asMap()
479
+ .entries
480
+ .map((entry) {
481
+ final item = entry.value;
482
+ return Container(
483
+ padding: const EdgeInsets.symmetric(
484
+ horizontal: 10,
485
+ vertical: 7,
486
+ ),
487
+ decoration: BoxDecoration(
488
+ color: _bgCard,
489
+ borderRadius: BorderRadius.circular(999),
490
+ border: Border.all(color: _border),
491
+ ),
492
+ child: Row(
493
+ mainAxisSize: MainAxisSize.min,
494
+ children: <Widget>[
495
+ Icon(
496
+ _iconForMime(item.mimeType),
497
+ size: 14,
498
+ color: _textSecondary,
499
+ ),
500
+ const SizedBox(width: 6),
501
+ ConstrainedBox(
502
+ constraints: const BoxConstraints(maxWidth: 170),
503
+ child: Text(
504
+ item.name,
505
+ maxLines: 1,
506
+ overflow: TextOverflow.ellipsis,
507
+ style: TextStyle(color: _textPrimary, fontSize: 12),
508
+ ),
509
+ ),
510
+ const SizedBox(width: 6),
511
+ GestureDetector(
512
+ onTap: () => onRemoveAt(entry.key),
513
+ child: Icon(
514
+ Icons.close_rounded,
515
+ size: 14,
516
+ color: _textMuted,
517
+ ),
518
+ ),
519
+ ],
520
+ ),
521
+ );
522
+ })
523
+ .toList(growable: false),
524
+ ),
525
+ ],
526
+ ),
527
+ );
528
+ }
529
+ }
530
+
271
531
  class _TypingIndicatorBubble extends StatefulWidget {
272
532
  const _TypingIndicatorBubble();
273
533