neoagent 2.3.1-beta.6 → 2.3.1-beta.60

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 (312) hide show
  1. package/.env.example +45 -0
  2. package/docs/capabilities.md +2 -2
  3. package/docs/configuration.md +6 -5
  4. package/docs/hardware.md +1 -1
  5. package/docs/integrations.md +2 -3
  6. package/flutter_app/.metadata +42 -0
  7. package/flutter_app/README.md +21 -0
  8. package/flutter_app/analysis_options.yaml +32 -0
  9. package/flutter_app/android/app/build.gradle.kts +111 -0
  10. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  11. package/flutter_app/android/app/src/main/AndroidManifest.xml +171 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +804 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/auto/NeoAgentCarAppService.kt +114 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnection.kt +118 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnectionService.kt +86 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  30. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  31. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  32. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  33. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  34. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  35. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  36. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  37. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  38. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  39. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  40. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  41. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  42. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  43. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  44. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  45. package/flutter_app/android/app/src/main/res/values/arrays.xml +8 -0
  46. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  47. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  48. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  49. package/flutter_app/android/app/src/main/res/xml/automotive_app_desc.xml +4 -0
  50. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  51. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  52. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  53. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  54. package/flutter_app/android/build.gradle.kts +24 -0
  55. package/flutter_app/android/ci-release.keystore +0 -0
  56. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  57. package/flutter_app/android/gradle.properties +3 -0
  58. package/flutter_app/android/key.properties +4 -0
  59. package/flutter_app/android/settings.gradle.kts +26 -0
  60. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  61. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  62. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  63. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  64. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  65. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  66. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  67. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  68. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  69. package/flutter_app/lib/features/location/location_service.dart +119 -0
  70. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  71. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +569 -0
  72. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +157 -0
  73. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +233 -0
  74. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +57 -0
  75. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +218 -0
  76. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +46 -0
  77. package/flutter_app/lib/main.dart +103 -0
  78. package/flutter_app/lib/main_account_settings.dart +1250 -0
  79. package/flutter_app/lib/main_admin.dart +886 -0
  80. package/flutter_app/lib/main_app_shell.dart +2134 -0
  81. package/flutter_app/lib/main_chat.dart +3316 -0
  82. package/flutter_app/lib/main_controller.dart +6836 -0
  83. package/flutter_app/lib/main_devices.dart +2396 -0
  84. package/flutter_app/lib/main_integrations.dart +909 -0
  85. package/flutter_app/lib/main_launcher.dart +959 -0
  86. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  87. package/flutter_app/lib/main_models.dart +3539 -0
  88. package/flutter_app/lib/main_navigation.dart +188 -0
  89. package/flutter_app/lib/main_operations.dart +4851 -0
  90. package/flutter_app/lib/main_recordings.dart +920 -0
  91. package/flutter_app/lib/main_runtime.dart +838 -0
  92. package/flutter_app/lib/main_settings.dart +2040 -0
  93. package/flutter_app/lib/main_shared.dart +3147 -0
  94. package/flutter_app/lib/main_theme.dart +257 -0
  95. package/flutter_app/lib/main_voice_assistant.dart +957 -0
  96. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  97. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  98. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  99. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  100. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  101. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  102. package/flutter_app/lib/src/android_auto_bridge.dart +59 -0
  103. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  104. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  105. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  106. package/flutter_app/lib/src/backend_client.dart +1837 -0
  107. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  108. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  109. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  110. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  111. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  112. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  113. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  114. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  115. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  116. package/flutter_app/lib/src/health_bridge.dart +136 -0
  117. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  118. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  119. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  120. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  121. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  122. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  123. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  124. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  125. package/flutter_app/lib/src/oauth_launcher_io.dart +95 -0
  126. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  127. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  128. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  129. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  130. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  131. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  132. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  133. package/flutter_app/lib/src/theme/palette.dart +81 -0
  134. package/flutter_app/lib/src/web_app_update_monitor.dart +17 -0
  135. package/flutter_app/lib/src/web_app_update_monitor_stub.dart +24 -0
  136. package/flutter_app/lib/src/web_app_update_monitor_web.dart +123 -0
  137. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  138. package/flutter_app/linux/CMakeLists.txt +128 -0
  139. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  140. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +47 -0
  141. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  142. package/flutter_app/linux/flutter/generated_plugins.cmake +32 -0
  143. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  144. package/flutter_app/linux/runner/main.cc +6 -0
  145. package/flutter_app/linux/runner/my_application.cc +144 -0
  146. package/flutter_app/linux/runner/my_application.h +18 -0
  147. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  148. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  149. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  150. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +44 -0
  151. package/flutter_app/macos/Podfile +42 -0
  152. package/flutter_app/macos/Podfile.lock +87 -0
  153. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  154. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  155. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  156. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  157. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  158. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  159. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  160. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  161. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  162. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  163. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  164. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  165. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  166. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  167. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  168. package/flutter_app/macos/Runner/Info.plist +36 -0
  169. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  170. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  171. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  172. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  173. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  174. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  175. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  176. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  177. package/flutter_app/patch_strings.py +12 -0
  178. package/flutter_app/pubspec.lock +1224 -0
  179. package/flutter_app/pubspec.yaml +57 -0
  180. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  181. package/flutter_app/test/recording_payloads_test.dart +53 -0
  182. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  183. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  184. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  185. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  186. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  187. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  188. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  189. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  190. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  191. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  192. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  193. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  194. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  195. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  196. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  197. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  198. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  199. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  200. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  201. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  202. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  203. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  204. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  205. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  206. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  207. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  208. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  209. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  210. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  211. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  212. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  213. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  214. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  215. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  216. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  217. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  218. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  219. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  220. package/flutter_app/web/favicon.png +0 -0
  221. package/flutter_app/web/favicon.svg +12 -0
  222. package/flutter_app/web/icons/Icon-192.png +0 -0
  223. package/flutter_app/web/icons/Icon-512.png +0 -0
  224. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  225. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  226. package/flutter_app/web/index.html +39 -0
  227. package/flutter_app/web/manifest.json +35 -0
  228. package/flutter_app/windows/CMakeLists.txt +108 -0
  229. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  230. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +50 -0
  231. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  232. package/flutter_app/windows/flutter/generated_plugins.cmake +36 -0
  233. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  234. package/flutter_app/windows/runner/Runner.rc +121 -0
  235. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  236. package/flutter_app/windows/runner/flutter_window.h +37 -0
  237. package/flutter_app/windows/runner/main.cpp +53 -0
  238. package/flutter_app/windows/runner/resource.h +16 -0
  239. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  240. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  241. package/flutter_app/windows/runner/utils.cpp +65 -0
  242. package/flutter_app/windows/runner/utils.h +19 -0
  243. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  244. package/flutter_app/windows/runner/win32_window.h +102 -0
  245. package/lib/install_helpers.js +31 -0
  246. package/lib/manager.js +201 -6
  247. package/package.json +5 -3
  248. package/server/db/database.js +125 -1
  249. package/server/http/middleware.js +55 -2
  250. package/server/http/routes.js +1 -0
  251. package/server/http/static.js +41 -1
  252. package/server/index.js +3 -0
  253. package/server/public/.last_build_id +1 -1
  254. package/server/public/assets/AssetManifest.bin +1 -1
  255. package/server/public/assets/AssetManifest.bin.json +1 -1
  256. package/server/public/assets/NOTICES +281 -1
  257. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  258. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  259. package/server/public/canvaskit/wimp.wasm +0 -0
  260. package/server/public/flutter_bootstrap.js +2 -2
  261. package/server/public/main.dart.js +86314 -82363
  262. package/server/routes/auth.js +26 -5
  263. package/server/routes/integrations.js +108 -1
  264. package/server/routes/memory.js +11 -2
  265. package/server/routes/settings.js +84 -2
  266. package/server/routes/wearable.js +67 -0
  267. package/server/services/ai/engine.js +3 -2
  268. package/server/services/ai/models.js +30 -0
  269. package/server/services/ai/providers/githubCopilot.js +97 -0
  270. package/server/services/ai/providers/openai.js +2 -1
  271. package/server/services/ai/providers/openaiCodex.js +31 -0
  272. package/server/services/ai/settings.js +20 -0
  273. package/server/services/ai/toolSelector.js +54 -1
  274. package/server/services/ai/tools.js +88 -5
  275. package/server/services/desktop/screenRecorder.js +65 -9
  276. package/server/services/integrations/env.js +5 -0
  277. package/server/services/integrations/figma/provider.js +1 -0
  278. package/server/services/integrations/github/common.js +106 -0
  279. package/server/services/integrations/github/provider.js +499 -0
  280. package/server/services/integrations/github/repos.js +1124 -0
  281. package/server/services/integrations/google/provider.js +1 -0
  282. package/server/services/integrations/manager.js +88 -12
  283. package/server/services/integrations/microsoft/provider.js +1 -0
  284. package/server/services/integrations/oauth_provider.js +25 -8
  285. package/server/services/integrations/provider_config_store.js +85 -0
  286. package/server/services/integrations/registry.js +4 -2
  287. package/server/services/integrations/spotify/provider.js +1 -0
  288. package/server/services/integrations/trello/provider.js +842 -0
  289. package/server/services/manager.js +46 -1
  290. package/server/services/mcp/client.js +120 -23
  291. package/server/services/memory/manager.js +39 -2
  292. package/server/services/messaging/access_policy.js +10 -0
  293. package/server/services/messaging/manager.js +49 -0
  294. package/server/services/messaging/meshtastic.js +277 -0
  295. package/server/services/messaging/meshtastic_env.js +40 -0
  296. package/server/services/messaging/meshtastic_protocol.js +530 -0
  297. package/server/services/messaging/meshtastic_protocol.test.js +33 -0
  298. package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
  299. package/server/services/tasks/runtime.js +1 -1
  300. package/server/services/voice/openaiClient.js +4 -1
  301. package/server/services/voice/openaiSpeech.js +6 -1
  302. package/server/services/voice/providers.js +144 -15
  303. package/server/services/voice/runtimeManager.js +156 -19
  304. package/server/services/voice/turnRunner.js +29 -9
  305. package/server/services/wearable/firmware_manifest.js +353 -0
  306. package/server/services/wearable/gateway.js +350 -0
  307. package/server/services/wearable/protocol.js +45 -0
  308. package/server/services/wearable/service.js +240 -0
  309. package/server/services/widgets/service.js +3 -0
  310. package/server/utils/local_secrets.js +56 -0
  311. package/server/utils/logger.js +37 -9
  312. package/server/services/integrations/home_assistant/provider.js +0 -350
@@ -0,0 +1,3316 @@
1
+ part of 'main.dart';
2
+
3
+ class ChatPanel extends StatefulWidget {
4
+ const ChatPanel({super.key, required this.controller});
5
+
6
+ final NeoAgentController controller;
7
+
8
+ @override
9
+ State<ChatPanel> createState() => _ChatPanelState();
10
+ }
11
+
12
+ class _ChatPanelState extends State<ChatPanel> {
13
+ late final TextEditingController _composerController;
14
+ final ScrollController _scrollController = ScrollController();
15
+ int _lastMessageCount = 0;
16
+ int _lastToolCount = 0;
17
+ String _lastStream = '';
18
+ bool _isSendingChatMessage = false;
19
+
20
+ @override
21
+ void initState() {
22
+ super.initState();
23
+ _composerController = TextEditingController();
24
+ widget.controller.addListener(_consumeQueuedDraft);
25
+ _consumeQueuedDraft();
26
+ }
27
+
28
+ @override
29
+ void dispose() {
30
+ widget.controller.removeListener(_consumeQueuedDraft);
31
+ _composerController.dispose();
32
+ _scrollController.dispose();
33
+ super.dispose();
34
+ }
35
+
36
+ void _consumeQueuedDraft() {
37
+ final draft = widget.controller.takePendingChatDraft();
38
+ if (draft == null || draft.isEmpty) {
39
+ return;
40
+ }
41
+ _composerController
42
+ ..text = draft
43
+ ..selection = TextSelection.collapsed(offset: draft.length);
44
+ }
45
+
46
+ @override
47
+ Widget build(BuildContext context) {
48
+ final controller = widget.controller;
49
+ final messages = controller.visibleChatMessages;
50
+ if (_lastMessageCount != messages.length ||
51
+ _lastToolCount != controller.toolEvents.length ||
52
+ _lastStream != controller.streamingAssistant) {
53
+ _lastMessageCount = messages.length;
54
+ _lastToolCount = controller.toolEvents.length;
55
+ _lastStream = controller.streamingAssistant;
56
+ WidgetsBinding.instance.addPostFrameCallback((_) {
57
+ if (_scrollController.hasClients) {
58
+ _scrollController.animateTo(
59
+ _scrollController.position.maxScrollExtent,
60
+ duration: const Duration(milliseconds: 220),
61
+ curve: Curves.easeOut,
62
+ );
63
+ }
64
+ });
65
+ }
66
+
67
+ return Column(
68
+ children: <Widget>[
69
+ Expanded(
70
+ child: ListView(
71
+ controller: _scrollController,
72
+ padding: _pagePadding(context),
73
+ children: <Widget>[
74
+ _PageTitle(
75
+ title: 'Chat',
76
+ subtitle: 'Live agent chat with tool and stream status.',
77
+ trailing: Wrap(
78
+ spacing: 10,
79
+ runSpacing: 10,
80
+ crossAxisAlignment: WrapCrossAlignment.center,
81
+ children: <Widget>[
82
+ FilledButton.icon(
83
+ onPressed: () => controller.setSelectedSection(
84
+ AppSection.voiceAssistant,
85
+ ),
86
+ icon: Icon(Icons.call),
87
+ label: Text('Call'),
88
+ ),
89
+ _MetaPill(
90
+ label: controller.modelIndicator,
91
+ icon: Icons.memory_outlined,
92
+ ),
93
+ _MetaPill(
94
+ label: 'Agent: ${controller.activeAgentLabel}',
95
+ icon: Icons.smart_toy_outlined,
96
+ ),
97
+ ],
98
+ ),
99
+ ),
100
+ if (controller.errorMessage != null) ...<Widget>[
101
+ _InlineError(message: controller.errorMessage!),
102
+ const SizedBox(height: 16),
103
+ ],
104
+ if (controller.activeRun != null ||
105
+ controller.toolEvents.isNotEmpty)
106
+ Padding(
107
+ padding: const EdgeInsets.only(bottom: 16),
108
+ child: _RunStatusPanel(
109
+ run: controller.activeRun,
110
+ tools: controller.toolEvents,
111
+ ),
112
+ ),
113
+ if (messages.isEmpty)
114
+ Padding(
115
+ padding: EdgeInsets.only(top: 64),
116
+ child: Center(
117
+ child: _EmptyState(
118
+ title: 'How can I help?',
119
+ subtitle:
120
+ 'Runs, tools, memory, scheduling, skills, and MCP are all available here.',
121
+ ),
122
+ ),
123
+ )
124
+ else
125
+ ...messages.map(
126
+ (entry) => Padding(
127
+ padding: const EdgeInsets.only(bottom: 18),
128
+ child: _ChatBubble(
129
+ entry: entry,
130
+ onLoadRunDetail: controller.fetchRunDetail,
131
+ ),
132
+ ),
133
+ ),
134
+ ],
135
+ ),
136
+ ),
137
+ Container(
138
+ padding: const EdgeInsets.fromLTRB(20, 14, 20, 20),
139
+ decoration: BoxDecoration(
140
+ color: _bgPrimary,
141
+ border: Border(top: BorderSide(color: _border)),
142
+ ),
143
+ child: Column(
144
+ children: <Widget>[
145
+ Container(
146
+ padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
147
+ decoration: BoxDecoration(
148
+ color: _bgTertiary,
149
+ borderRadius: BorderRadius.circular(14),
150
+ border: Border.all(color: _border),
151
+ ),
152
+ child: Row(
153
+ crossAxisAlignment: CrossAxisAlignment.end,
154
+ children: <Widget>[
155
+ Expanded(
156
+ child: TextField(
157
+ controller: _composerController,
158
+ minLines: 1,
159
+ maxLines: 6,
160
+ keyboardType: TextInputType.multiline,
161
+ textInputAction: TextInputAction.newline,
162
+ decoration: InputDecoration(
163
+ hintText: controller.chatComposerHint,
164
+ isDense: true,
165
+ filled: false,
166
+ border: InputBorder.none,
167
+ enabledBorder: InputBorder.none,
168
+ focusedBorder: InputBorder.none,
169
+ ),
170
+ ),
171
+ ),
172
+ const SizedBox(width: 8),
173
+ FilledButton(
174
+ onPressed: () => controller.setSelectedSection(
175
+ AppSection.voiceAssistant,
176
+ ),
177
+ style: FilledButton.styleFrom(
178
+ minimumSize: const Size(46, 42),
179
+ padding: const EdgeInsets.symmetric(horizontal: 12),
180
+ backgroundColor: _success,
181
+ shape: RoundedRectangleBorder(
182
+ borderRadius: BorderRadius.circular(10),
183
+ ),
184
+ ),
185
+ child: Icon(Icons.call_rounded, color: Colors.white),
186
+ ),
187
+ const SizedBox(width: 8),
188
+ FilledButton(
189
+ onPressed: _isSendingChatMessage
190
+ ? null
191
+ : () async {
192
+ final task = _composerController.text;
193
+ if (task.trim().isEmpty ||
194
+ _isSendingChatMessage) {
195
+ return;
196
+ }
197
+ setState(() {
198
+ _isSendingChatMessage = true;
199
+ });
200
+ _composerController.clear();
201
+ try {
202
+ await controller.sendMessage(task);
203
+ } finally {
204
+ if (mounted) {
205
+ setState(() {
206
+ _isSendingChatMessage = false;
207
+ });
208
+ }
209
+ }
210
+ },
211
+ style: FilledButton.styleFrom(
212
+ minimumSize: const Size(46, 42),
213
+ padding: const EdgeInsets.symmetric(horizontal: 12),
214
+ backgroundColor: _accent,
215
+ shape: RoundedRectangleBorder(
216
+ borderRadius: BorderRadius.circular(10),
217
+ ),
218
+ ),
219
+ child: Icon(
220
+ controller.hasLiveRun
221
+ ? Icons.alt_route_rounded
222
+ : Icons.north_east_rounded,
223
+ color: Colors.white,
224
+ ),
225
+ ),
226
+ ],
227
+ ),
228
+ ),
229
+ const SizedBox(height: 8),
230
+ Row(
231
+ children: <Widget>[
232
+ Expanded(
233
+ child: Text(
234
+ controller.chatStatusLabel,
235
+ maxLines: 1,
236
+ overflow: TextOverflow.ellipsis,
237
+ style: TextStyle(fontSize: 11, color: _textSecondary),
238
+ ),
239
+ ),
240
+ const SizedBox(width: 12),
241
+ Flexible(
242
+ child: Text(
243
+ controller.hasLiveRun
244
+ ? 'Steering mode'
245
+ : controller.modelIndicator,
246
+ maxLines: 1,
247
+ overflow: TextOverflow.ellipsis,
248
+ textAlign: TextAlign.right,
249
+ style: TextStyle(fontSize: 11, color: _textSecondary),
250
+ ),
251
+ ),
252
+ ],
253
+ ),
254
+ ],
255
+ ),
256
+ ),
257
+ ],
258
+ );
259
+ }
260
+ }
261
+
262
+ class MessagingPanel extends StatefulWidget {
263
+ const MessagingPanel({super.key, required this.controller});
264
+
265
+ final NeoAgentController controller;
266
+
267
+ @override
268
+ State<MessagingPanel> createState() => _MessagingPanelState();
269
+ }
270
+
271
+ MessagingPlatformDescriptor? _messagingPlatformById(String id) {
272
+ for (final platform in messagingPlatforms) {
273
+ if (platform.id == id) return platform;
274
+ }
275
+ return null;
276
+ }
277
+
278
+ class _MessagingPanelState extends State<MessagingPanel> {
279
+ final TextEditingController _searchController = TextEditingController();
280
+ String _statusFilter = 'all';
281
+
282
+ @override
283
+ void initState() {
284
+ super.initState();
285
+ _searchController.addListener(_handleSearchChanged);
286
+ }
287
+
288
+ @override
289
+ void dispose() {
290
+ _searchController
291
+ ..removeListener(_handleSearchChanged)
292
+ ..dispose();
293
+ super.dispose();
294
+ }
295
+
296
+ void _handleSearchChanged() => setState(() {});
297
+
298
+ @override
299
+ Widget build(BuildContext context) {
300
+ final controller = widget.controller;
301
+ final groups = [
302
+ const (
303
+ 'Text & Chat',
304
+ 'Personal channels and direct support surfaces.',
305
+ [
306
+ 'whatsapp',
307
+ 'signal',
308
+ 'imessage',
309
+ 'bluebubbles',
310
+ 'line',
311
+ 'zalo_personal',
312
+ ],
313
+ ),
314
+ const (
315
+ 'Community & ChatOps',
316
+ 'Team spaces, rooms, channels, and live communities.',
317
+ [
318
+ 'discord',
319
+ 'telegram',
320
+ 'slack',
321
+ 'google_chat',
322
+ 'teams',
323
+ 'matrix',
324
+ 'mattermost',
325
+ 'irc',
326
+ 'twitch',
327
+ ],
328
+ ),
329
+ const (
330
+ 'Configurable Webhooks',
331
+ 'Bridge any provider that can post and receive webhook payloads.',
332
+ [
333
+ 'feishu',
334
+ 'nextcloud_talk',
335
+ 'nostr',
336
+ 'synology_chat',
337
+ 'tlon',
338
+ 'zalo',
339
+ 'wechat',
340
+ 'webchat',
341
+ ],
342
+ ),
343
+ const (
344
+ 'Hardware Bridges',
345
+ 'Local device bridges and TCP-connected integrations.',
346
+ [
347
+ 'meshtastic',
348
+ ],
349
+ ),
350
+ const ('Voice', 'Telephony integrations.', ['telnyx']),
351
+ ];
352
+ final query = _searchController.text.trim().toLowerCase();
353
+ final counts = _MessagingStatusCounts.from(controller.messagingStatuses);
354
+ final hasMatches = _hasMessagingMatches(controller, groups, query);
355
+
356
+ return ListView(
357
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
358
+ children: [
359
+ _PageTitle(
360
+ title: 'Messaging',
361
+ subtitle:
362
+ 'Connect channels, limit who can reach the agent, and monitor activity.',
363
+ trailing: OutlinedButton.icon(
364
+ onPressed: controller.refreshMessaging,
365
+ icon: Icon(Icons.refresh_rounded),
366
+ label: Text('Refresh'),
367
+ ),
368
+ ),
369
+ const SizedBox(height: 18),
370
+ _MessagingOverviewStrip(counts: counts),
371
+ const SizedBox(height: 16),
372
+ _MessagingToolbar(
373
+ controller: _searchController,
374
+ selectedFilter: _statusFilter,
375
+ onFilterChanged: (value) => setState(() => _statusFilter = value),
376
+ counts: counts,
377
+ ),
378
+ if (controller.pendingMessagingQr != null) ...[
379
+ const SizedBox(height: 18),
380
+ _MessagingQrPanel(qrState: controller.pendingMessagingQr!),
381
+ ],
382
+ const SizedBox(height: 18),
383
+ for (final group in groups)
384
+ Builder(
385
+ builder: (context) {
386
+ final platforms = group.$3
387
+ .map(_messagingPlatformById)
388
+ .nonNulls
389
+ .where((platform) {
390
+ final status =
391
+ controller.messagingStatuses[platform.id] ??
392
+ MessagingPlatformStatus.empty(platform.id);
393
+ final haystack =
394
+ '${platform.label} ${platform.subtitle} ${group.$1}'
395
+ .toLowerCase();
396
+ return _matchesMessagingStatusFilter(status) &&
397
+ (query.isEmpty || haystack.contains(query));
398
+ })
399
+ .toList(growable: false);
400
+ if (platforms.isEmpty) return const SizedBox.shrink();
401
+ return Padding(
402
+ padding: const EdgeInsets.only(bottom: 22),
403
+ child: Column(
404
+ crossAxisAlignment: CrossAxisAlignment.start,
405
+ children: [
406
+ _MessagingGroupHeader(
407
+ title: group.$1,
408
+ subtitle: group.$2,
409
+ count: platforms.length,
410
+ ),
411
+ const SizedBox(height: 12),
412
+ LayoutBuilder(
413
+ builder: (context, constraints) {
414
+ final width = constraints.maxWidth;
415
+ final crossAxisCount = width >= 1380
416
+ ? 4
417
+ : width >= 1020
418
+ ? 3
419
+ : width >= 700
420
+ ? 2
421
+ : 1;
422
+ return GridView.builder(
423
+ shrinkWrap: true,
424
+ physics: const NeverScrollableScrollPhysics(),
425
+ itemCount: platforms.length,
426
+ gridDelegate:
427
+ SliverGridDelegateWithFixedCrossAxisCount(
428
+ crossAxisCount: crossAxisCount,
429
+ crossAxisSpacing: 12,
430
+ mainAxisSpacing: 12,
431
+ mainAxisExtent: 268,
432
+ ),
433
+ itemBuilder: (context, index) {
434
+ final platform = platforms[index];
435
+ return _MessagingCard(
436
+ platform: platform,
437
+ status:
438
+ controller.messagingStatuses[platform.id] ??
439
+ MessagingPlatformStatus.empty(platform.id),
440
+ accessCatalog: controller
441
+ .currentMessagingAccessCatalog(platform.id),
442
+ controller: controller,
443
+ onConnect: () => openMessagingConfig(context, controller, platform),
444
+ onDisconnect: () => controller
445
+ .disconnectMessagingPlatform(platform.id),
446
+ onLogout: () => controller
447
+ .logoutMessagingPlatform(platform.id),
448
+ );
449
+ },
450
+ );
451
+ },
452
+ ),
453
+ ],
454
+ ),
455
+ );
456
+ },
457
+ ),
458
+ if (!hasMatches) ...[
459
+ const SizedBox(height: 10),
460
+ const _EmptyCard(
461
+ title: 'No platforms match',
462
+ subtitle:
463
+ 'Adjust the search or status filter to see more messaging channels.',
464
+ ),
465
+ const SizedBox(height: 22),
466
+ ],
467
+ _MessagingActivityPanel(messages: controller.messagingMessages),
468
+ ],
469
+ );
470
+ }
471
+
472
+ bool _hasMessagingMatches(
473
+ NeoAgentController controller,
474
+ List<(String, String, List<String>)> groups,
475
+ String query,
476
+ ) {
477
+ for (final group in groups) {
478
+ for (final key in group.$3) {
479
+ final platform = _messagingPlatformById(key);
480
+ if (platform == null) continue;
481
+ final status =
482
+ controller.messagingStatuses[platform.id] ??
483
+ MessagingPlatformStatus.empty(platform.id);
484
+ final haystack = '${platform.label} ${platform.subtitle} ${group.$1}'
485
+ .toLowerCase();
486
+ if (_matchesMessagingStatusFilter(status) &&
487
+ (query.isEmpty || haystack.contains(query))) {
488
+ return true;
489
+ }
490
+ }
491
+ }
492
+ return false;
493
+ }
494
+
495
+ bool _matchesMessagingStatusFilter(MessagingPlatformStatus? status) {
496
+ final effective = status ?? MessagingPlatformStatus.empty('unknown');
497
+ return switch (_statusFilter) {
498
+ 'connected' => effective.isConnected,
499
+ 'configured' => effective.status != 'not_configured',
500
+ 'attention' => const {
501
+ 'connecting',
502
+ 'awaiting_qr',
503
+ 'logged_out',
504
+ 'disconnected',
505
+ 'error',
506
+ }.contains(effective.status),
507
+ _ => true,
508
+ };
509
+ }
510
+ }
511
+
512
+ class _MessagingStatusCounts {
513
+ const _MessagingStatusCounts({
514
+ required this.total,
515
+ required this.connected,
516
+ required this.configured,
517
+ required this.attention,
518
+ });
519
+
520
+ final int total;
521
+ final int connected;
522
+ final int configured;
523
+ final int attention;
524
+
525
+ factory _MessagingStatusCounts.from(
526
+ Map<String, MessagingPlatformStatus> statuses,
527
+ ) {
528
+ var connected = 0;
529
+ var configured = 0;
530
+ var attention = 0;
531
+ for (final platform in messagingPlatforms) {
532
+ final status =
533
+ statuses[platform.id] ?? MessagingPlatformStatus.empty(platform.id);
534
+ if (status.isConnected) connected++;
535
+ if (status.status != 'not_configured') configured++;
536
+ if (const {
537
+ 'connecting',
538
+ 'awaiting_qr',
539
+ 'logged_out',
540
+ 'disconnected',
541
+ 'error',
542
+ }.contains(status.status)) {
543
+ attention++;
544
+ }
545
+ }
546
+ return _MessagingStatusCounts(
547
+ total: messagingPlatforms.length,
548
+ connected: connected,
549
+ configured: configured,
550
+ attention: attention,
551
+ );
552
+ }
553
+ }
554
+
555
+ class _MessagingOverviewStrip extends StatelessWidget {
556
+ const _MessagingOverviewStrip({required this.counts});
557
+
558
+ final _MessagingStatusCounts counts;
559
+
560
+ @override
561
+ Widget build(BuildContext context) {
562
+ final cards = [
563
+ _MessagingMetricCard(
564
+ icon: Icons.link_rounded,
565
+ label: 'Connected',
566
+ value: '${counts.connected}',
567
+ helper: '${counts.configured} configured',
568
+ color: _success,
569
+ ),
570
+ _MessagingMetricCard(
571
+ icon: Icons.error_outline_rounded,
572
+ label: 'Needs attention',
573
+ value: '${counts.attention}',
574
+ helper: 'Reconnect or finish setup',
575
+ color: counts.attention > 0 ? _warning : _textSecondary,
576
+ ),
577
+ _MessagingMetricCard(
578
+ icon: Icons.apps_rounded,
579
+ label: 'Available',
580
+ value: '${counts.total}',
581
+ helper: 'Native and webhook channels',
582
+ color: _info,
583
+ ),
584
+ ];
585
+ return LayoutBuilder(
586
+ builder: (context, constraints) {
587
+ final compact = constraints.maxWidth < 760;
588
+ if (compact) {
589
+ return Column(
590
+ children: [
591
+ for (var index = 0; index < cards.length; index++) ...[
592
+ if (index > 0) const SizedBox(height: 10),
593
+ cards[index],
594
+ ],
595
+ ],
596
+ );
597
+ }
598
+ return Row(
599
+ children: [
600
+ for (var index = 0; index < cards.length; index++) ...[
601
+ if (index > 0) const SizedBox(width: 12),
602
+ Expanded(child: cards[index]),
603
+ ],
604
+ ],
605
+ );
606
+ },
607
+ );
608
+ }
609
+ }
610
+
611
+ class _MessagingMetricCard extends StatelessWidget {
612
+ const _MessagingMetricCard({
613
+ required this.icon,
614
+ required this.label,
615
+ required this.value,
616
+ required this.helper,
617
+ required this.color,
618
+ });
619
+
620
+ final IconData icon;
621
+ final String label;
622
+ final String value;
623
+ final String helper;
624
+ final Color color;
625
+
626
+ @override
627
+ Widget build(BuildContext context) {
628
+ return Container(
629
+ padding: const EdgeInsets.all(16),
630
+ decoration: BoxDecoration(
631
+ color: _bgCard,
632
+ borderRadius: BorderRadius.circular(8),
633
+ border: Border.all(color: _borderLight),
634
+ ),
635
+ child: Row(
636
+ children: [
637
+ Container(
638
+ width: 40,
639
+ height: 40,
640
+ decoration: BoxDecoration(
641
+ color: color.withValues(alpha: 0.12),
642
+ borderRadius: BorderRadius.circular(8),
643
+ ),
644
+ child: Icon(icon, color: color, size: 22),
645
+ ),
646
+ const SizedBox(width: 14),
647
+ Expanded(
648
+ child: Column(
649
+ crossAxisAlignment: CrossAxisAlignment.start,
650
+ children: [
651
+ Text(
652
+ label,
653
+ style: TextStyle(color: _textSecondary, fontSize: 12),
654
+ ),
655
+ const SizedBox(height: 4),
656
+ Text(
657
+ value,
658
+ style: TextStyle(
659
+ color: _textPrimary,
660
+ fontSize: 26,
661
+ fontWeight: FontWeight.w800,
662
+ ),
663
+ ),
664
+ const SizedBox(height: 2),
665
+ Text(
666
+ helper,
667
+ style: TextStyle(color: _textMuted, fontSize: 12),
668
+ overflow: TextOverflow.ellipsis,
669
+ ),
670
+ ],
671
+ ),
672
+ ),
673
+ ],
674
+ ),
675
+ );
676
+ }
677
+ }
678
+
679
+ class _MessagingToolbar extends StatelessWidget {
680
+ const _MessagingToolbar({
681
+ required this.controller,
682
+ required this.selectedFilter,
683
+ required this.onFilterChanged,
684
+ required this.counts,
685
+ });
686
+
687
+ final TextEditingController controller;
688
+ final String selectedFilter;
689
+ final ValueChanged<String> onFilterChanged;
690
+ final _MessagingStatusCounts counts;
691
+
692
+ @override
693
+ Widget build(BuildContext context) {
694
+ final filters = <(String, String)>[
695
+ ('all', 'All ${counts.total}'),
696
+ ('connected', 'Connected ${counts.connected}'),
697
+ ('configured', 'Configured ${counts.configured}'),
698
+ ('attention', 'Attention ${counts.attention}'),
699
+ ];
700
+ return Container(
701
+ padding: const EdgeInsets.all(14),
702
+ decoration: BoxDecoration(
703
+ color: _bgSecondary,
704
+ borderRadius: BorderRadius.circular(8),
705
+ border: Border.all(color: _borderLight),
706
+ ),
707
+ child: LayoutBuilder(
708
+ builder: (context, constraints) {
709
+ final compact = constraints.maxWidth < 780;
710
+ final search = TextField(
711
+ controller: controller,
712
+ style: TextStyle(color: _textPrimary),
713
+ decoration: InputDecoration(
714
+ labelText: 'Find a platform',
715
+ prefixIcon: Icon(Icons.search_rounded),
716
+ suffixIcon: controller.text.isEmpty
717
+ ? null
718
+ : IconButton(
719
+ onPressed: controller.clear,
720
+ icon: Icon(Icons.close_rounded),
721
+ ),
722
+ ),
723
+ );
724
+ final chips = Wrap(
725
+ spacing: 8,
726
+ runSpacing: 8,
727
+ children: [
728
+ for (final filter in filters)
729
+ ChoiceChip(
730
+ label: Text(filter.$2),
731
+ selected: selectedFilter == filter.$1,
732
+ onSelected: (_) => onFilterChanged(filter.$1),
733
+ selectedColor: _accent.withValues(alpha: 0.18),
734
+ backgroundColor: _bgCard,
735
+ side: BorderSide(
736
+ color: selectedFilter == filter.$1
737
+ ? _accent.withValues(alpha: 0.42)
738
+ : _borderLight,
739
+ ),
740
+ labelStyle: TextStyle(
741
+ color: selectedFilter == filter.$1
742
+ ? _textPrimary
743
+ : _textSecondary,
744
+ fontWeight: selectedFilter == filter.$1
745
+ ? FontWeight.w700
746
+ : FontWeight.w500,
747
+ ),
748
+ ),
749
+ ],
750
+ );
751
+ if (compact) {
752
+ return Column(
753
+ crossAxisAlignment: CrossAxisAlignment.start,
754
+ children: [search, const SizedBox(height: 12), chips],
755
+ );
756
+ }
757
+ return Row(
758
+ children: [
759
+ Expanded(child: search),
760
+ const SizedBox(width: 14),
761
+ Flexible(child: chips),
762
+ ],
763
+ );
764
+ },
765
+ ),
766
+ );
767
+ }
768
+ }
769
+
770
+ class _MessagingQrPanel extends StatelessWidget {
771
+ const _MessagingQrPanel({required this.qrState});
772
+
773
+ final MessagingQrState qrState;
774
+
775
+ @override
776
+ Widget build(BuildContext context) {
777
+ final qrImage = Container(
778
+ padding: const EdgeInsets.all(10),
779
+ decoration: BoxDecoration(
780
+ color: Colors.white,
781
+ borderRadius: BorderRadius.circular(8),
782
+ ),
783
+ child: QrImageView(
784
+ data: qrState.qr,
785
+ size: 168,
786
+ eyeStyle: const QrEyeStyle(
787
+ eyeShape: QrEyeShape.square,
788
+ color: Colors.black,
789
+ ),
790
+ dataModuleStyle: const QrDataModuleStyle(
791
+ dataModuleShape: QrDataModuleShape.square,
792
+ color: Colors.black,
793
+ ),
794
+ ),
795
+ );
796
+ final copy = Column(
797
+ crossAxisAlignment: CrossAxisAlignment.start,
798
+ children: [
799
+ _StatusPill(label: 'Awaiting scan', color: _warning),
800
+ const SizedBox(height: 12),
801
+ Text(
802
+ 'Scan to finish ${qrState.platformLabel}',
803
+ style: TextStyle(
804
+ color: _textPrimary,
805
+ fontSize: 22,
806
+ fontWeight: FontWeight.w800,
807
+ ),
808
+ ),
809
+ const SizedBox(height: 8),
810
+ Text(
811
+ 'Keep this panel open until the platform confirms the connection.',
812
+ style: TextStyle(color: _textSecondary, height: 1.45),
813
+ ),
814
+ ],
815
+ );
816
+ return Container(
817
+ padding: const EdgeInsets.all(18),
818
+ decoration: BoxDecoration(
819
+ color: _warning.withValues(alpha: 0.08),
820
+ borderRadius: BorderRadius.circular(8),
821
+ border: Border.all(color: _warning.withValues(alpha: 0.3)),
822
+ ),
823
+ child: LayoutBuilder(
824
+ builder: (context, constraints) {
825
+ if (constraints.maxWidth < 680) {
826
+ return Column(
827
+ crossAxisAlignment: CrossAxisAlignment.start,
828
+ children: [
829
+ copy,
830
+ const SizedBox(height: 16),
831
+ Center(child: qrImage),
832
+ ],
833
+ );
834
+ }
835
+ return Row(
836
+ children: [
837
+ Expanded(child: copy),
838
+ const SizedBox(width: 24),
839
+ qrImage,
840
+ ],
841
+ );
842
+ },
843
+ ),
844
+ );
845
+ }
846
+ }
847
+
848
+ class _MessagingGroupHeader extends StatelessWidget {
849
+ const _MessagingGroupHeader({
850
+ required this.title,
851
+ required this.subtitle,
852
+ required this.count,
853
+ });
854
+
855
+ final String title;
856
+ final String subtitle;
857
+ final int count;
858
+
859
+ @override
860
+ Widget build(BuildContext context) {
861
+ return Row(
862
+ crossAxisAlignment: CrossAxisAlignment.start,
863
+ children: [
864
+ Expanded(
865
+ child: Column(
866
+ crossAxisAlignment: CrossAxisAlignment.start,
867
+ children: [
868
+ Text(
869
+ title,
870
+ style: TextStyle(
871
+ color: _textPrimary,
872
+ fontSize: 18,
873
+ fontWeight: FontWeight.w800,
874
+ ),
875
+ ),
876
+ const SizedBox(height: 4),
877
+ Text(
878
+ subtitle,
879
+ style: TextStyle(color: _textSecondary, height: 1.35),
880
+ ),
881
+ ],
882
+ ),
883
+ ),
884
+ const SizedBox(width: 12),
885
+ _StatusPill(label: '$count shown', color: _textSecondary),
886
+ ],
887
+ );
888
+ }
889
+ }
890
+
891
+ class _MessagingActivityPanel extends StatelessWidget {
892
+ const _MessagingActivityPanel({required this.messages});
893
+
894
+ final List<MessagingMessage> messages;
895
+
896
+ @override
897
+ Widget build(BuildContext context) {
898
+ return Container(
899
+ padding: const EdgeInsets.all(18),
900
+ decoration: BoxDecoration(
901
+ color: _bgCard,
902
+ borderRadius: BorderRadius.circular(8),
903
+ border: Border.all(color: _borderLight),
904
+ ),
905
+ child: Column(
906
+ crossAxisAlignment: CrossAxisAlignment.start,
907
+ children: [
908
+ Row(
909
+ children: [
910
+ Expanded(
911
+ child: Text(
912
+ 'Recent Channel Activity',
913
+ style: TextStyle(
914
+ color: _textPrimary,
915
+ fontSize: 18,
916
+ fontWeight: FontWeight.w800,
917
+ ),
918
+ ),
919
+ ),
920
+ _StatusPill(label: '${messages.length} events', color: _info),
921
+ ],
922
+ ),
923
+ const SizedBox(height: 14),
924
+ if (messages.isEmpty)
925
+ const _EmptyCard(
926
+ title: 'No recent channel activity',
927
+ subtitle:
928
+ 'Incoming and outgoing channel messages will appear here.',
929
+ )
930
+ else
931
+ Column(
932
+ children: [
933
+ for (final message in messages.take(12))
934
+ _MessagingActivityItem(message: message),
935
+ ],
936
+ ),
937
+ ],
938
+ ),
939
+ );
940
+ }
941
+ }
942
+
943
+ class _MessagingActivityItem extends StatelessWidget {
944
+ const _MessagingActivityItem({required this.message});
945
+
946
+ final MessagingMessage message;
947
+
948
+ @override
949
+ Widget build(BuildContext context) {
950
+ final isOutbound = message.outgoing;
951
+ final color = isOutbound ? _accent : _success;
952
+ return Container(
953
+ margin: const EdgeInsets.only(bottom: 10),
954
+ padding: const EdgeInsets.all(12),
955
+ decoration: BoxDecoration(
956
+ color: _bgSecondary,
957
+ borderRadius: BorderRadius.circular(8),
958
+ border: Border.all(color: _borderLight),
959
+ ),
960
+ child: Row(
961
+ crossAxisAlignment: CrossAxisAlignment.start,
962
+ children: [
963
+ Container(
964
+ width: 36,
965
+ height: 36,
966
+ decoration: BoxDecoration(
967
+ color: color.withValues(alpha: 0.12),
968
+ borderRadius: BorderRadius.circular(8),
969
+ ),
970
+ child: Icon(
971
+ isOutbound ? Icons.north_east_rounded : Icons.south_west_rounded,
972
+ color: color,
973
+ size: 18,
974
+ ),
975
+ ),
976
+ const SizedBox(width: 12),
977
+ Expanded(
978
+ child: Column(
979
+ crossAxisAlignment: CrossAxisAlignment.start,
980
+ children: [
981
+ Wrap(
982
+ spacing: 8,
983
+ runSpacing: 6,
984
+ crossAxisAlignment: WrapCrossAlignment.center,
985
+ children: [
986
+ _StatusPill(
987
+ label: message.platform.toUpperCase(),
988
+ color: _info,
989
+ ),
990
+ Text(
991
+ message.senderLabel,
992
+ style: TextStyle(
993
+ color: _textPrimary,
994
+ fontWeight: FontWeight.w700,
995
+ ),
996
+ ),
997
+ Text(
998
+ message.createdAtLabel,
999
+ style: TextStyle(color: _textMuted, fontSize: 12),
1000
+ ),
1001
+ ],
1002
+ ),
1003
+ const SizedBox(height: 6),
1004
+ Text(
1005
+ message.content.ifEmpty('[empty]'),
1006
+ style: TextStyle(color: _textSecondary, height: 1.35),
1007
+ maxLines: 3,
1008
+ overflow: TextOverflow.ellipsis,
1009
+ ),
1010
+ ],
1011
+ ),
1012
+ ),
1013
+ ],
1014
+ ),
1015
+ );
1016
+ }
1017
+ }
1018
+
1019
+ class RunsPanel extends StatefulWidget {
1020
+ const RunsPanel({super.key, required this.controller});
1021
+
1022
+ final NeoAgentController controller;
1023
+
1024
+ @override
1025
+ State<RunsPanel> createState() => _RunsPanelState();
1026
+ }
1027
+
1028
+ class _RunsPanelState extends State<RunsPanel> {
1029
+ late final TextEditingController _searchController;
1030
+ String? _selectedRunId;
1031
+ String _statusFilter = 'all';
1032
+ RunDetailSnapshot? _detail;
1033
+ bool _loadingDetail = false;
1034
+ String? _detailError;
1035
+
1036
+ @override
1037
+ void initState() {
1038
+ super.initState();
1039
+ _searchController = TextEditingController()
1040
+ ..addListener(_handleSearchChanged);
1041
+ _syncSelection();
1042
+ }
1043
+
1044
+ @override
1045
+ void dispose() {
1046
+ _searchController
1047
+ ..removeListener(_handleSearchChanged)
1048
+ ..dispose();
1049
+ super.dispose();
1050
+ }
1051
+
1052
+ @override
1053
+ void didUpdateWidget(covariant RunsPanel oldWidget) {
1054
+ super.didUpdateWidget(oldWidget);
1055
+ _syncSelection();
1056
+ }
1057
+
1058
+ void _handleSearchChanged() {
1059
+ if (!mounted) {
1060
+ return;
1061
+ }
1062
+ setState(() {});
1063
+ _syncSelection();
1064
+ }
1065
+
1066
+ List<RunSummary> get _filteredRuns {
1067
+ final query = _searchController.text.trim().toLowerCase();
1068
+ return widget.controller.recentRuns.where((run) {
1069
+ final statusMatches =
1070
+ _statusFilter == 'all' ||
1071
+ (_statusFilter == 'failed'
1072
+ ? run.isFailure
1073
+ : run.status.toLowerCase() == _statusFilter);
1074
+ if (!statusMatches) {
1075
+ return false;
1076
+ }
1077
+ if (query.isEmpty) {
1078
+ return true;
1079
+ }
1080
+ final haystack = <String>[
1081
+ run.title,
1082
+ run.status,
1083
+ run.model,
1084
+ run.triggerSource,
1085
+ run.error,
1086
+ run.id,
1087
+ ].join(' ').toLowerCase();
1088
+ return haystack.contains(query);
1089
+ }).toList();
1090
+ }
1091
+
1092
+ void _syncSelection() {
1093
+ final runs = _filteredRuns;
1094
+ if (runs.isEmpty) {
1095
+ _selectedRunId = null;
1096
+ _detail = null;
1097
+ _detailError = null;
1098
+ return;
1099
+ }
1100
+ if (_selectedRunId == null ||
1101
+ !runs.any((run) => run.id == _selectedRunId)) {
1102
+ _selectRun(runs.first.id);
1103
+ }
1104
+ }
1105
+
1106
+ Future<void> _selectRun(String runId, {bool force = false}) async {
1107
+ setState(() {
1108
+ _selectedRunId = runId;
1109
+ _loadingDetail = true;
1110
+ _detailError = null;
1111
+ });
1112
+ try {
1113
+ final detail = await widget.controller.fetchRunDetail(
1114
+ runId,
1115
+ force: force,
1116
+ );
1117
+ if (!mounted || _selectedRunId != runId) {
1118
+ return;
1119
+ }
1120
+ setState(() {
1121
+ _detail = detail;
1122
+ _loadingDetail = false;
1123
+ _detailError = null;
1124
+ });
1125
+ } catch (error, stackTrace) {
1126
+ AppDiagnostics.log(
1127
+ 'runs.ui',
1128
+ 'detail.fetch_failed',
1129
+ data: <String, Object?>{'runId': runId},
1130
+ error: error,
1131
+ stackTrace: stackTrace,
1132
+ );
1133
+ if (!mounted || _selectedRunId != runId) {
1134
+ return;
1135
+ }
1136
+ setState(() {
1137
+ _loadingDetail = false;
1138
+ _detailError = widget.controller.friendlyErrorMessage(error);
1139
+ });
1140
+ }
1141
+ }
1142
+
1143
+ Future<void> _refreshRuns() async {
1144
+ await widget.controller.refreshRunsOnly();
1145
+ if (!mounted) {
1146
+ return;
1147
+ }
1148
+ final selectedRunId = _selectedRunId;
1149
+ if (selectedRunId != null &&
1150
+ _filteredRuns.any((run) => run.id == selectedRunId)) {
1151
+ await _selectRun(selectedRunId, force: true);
1152
+ } else {
1153
+ _syncSelection();
1154
+ }
1155
+ setState(() {});
1156
+ }
1157
+
1158
+ void _setStatusFilter(String value) {
1159
+ setState(() {
1160
+ _statusFilter = value;
1161
+ });
1162
+ _syncSelection();
1163
+ }
1164
+
1165
+ Future<void> _copyResponse(String response) async {
1166
+ if (response.trim().isEmpty) {
1167
+ return;
1168
+ }
1169
+ await Clipboard.setData(ClipboardData(text: response));
1170
+ if (!mounted) {
1171
+ return;
1172
+ }
1173
+ ScaffoldMessenger.of(
1174
+ context,
1175
+ ).showSnackBar(const SnackBar(content: Text('Copied final response')));
1176
+ }
1177
+
1178
+ Future<void> _deleteSelectedRun() async {
1179
+ final run = widget.controller.recentRuns.cast<RunSummary?>().firstWhere(
1180
+ (item) => item?.id == _selectedRunId,
1181
+ orElse: () => null,
1182
+ );
1183
+ if (run == null) {
1184
+ return;
1185
+ }
1186
+ await _confirmDelete(
1187
+ context,
1188
+ title: 'Delete run?',
1189
+ message:
1190
+ 'Remove "${run.title}" and its recorded steps from the run history?',
1191
+ onConfirm: () async {
1192
+ await widget.controller.deleteRun(run.id);
1193
+ if (!mounted) {
1194
+ return;
1195
+ }
1196
+ _syncSelection();
1197
+ setState(() {});
1198
+ },
1199
+ );
1200
+ }
1201
+
1202
+ @override
1203
+ Widget build(BuildContext context) {
1204
+ final controller = widget.controller;
1205
+ final filteredRuns = _filteredRuns;
1206
+ final selected = filteredRuns.cast<RunSummary?>().firstWhere(
1207
+ (run) => run?.id == _selectedRunId,
1208
+ orElse: () => null,
1209
+ );
1210
+ final detail = _detail?.run.id == selected?.id ? _detail : null;
1211
+
1212
+ return ListView(
1213
+ padding: _pagePadding(context),
1214
+ children: <Widget>[
1215
+ _PageTitle(
1216
+ title: 'Runs',
1217
+ subtitle:
1218
+ 'Inspect recent runs, failures, tool steps, and final responses.',
1219
+ trailing: OutlinedButton.icon(
1220
+ onPressed: _refreshRuns,
1221
+ icon: Icon(Icons.refresh),
1222
+ label: Text('Refresh'),
1223
+ ),
1224
+ ),
1225
+ if (controller.errorMessage != null) ...<Widget>[
1226
+ _InlineError(message: controller.errorMessage!),
1227
+ const SizedBox(height: 16),
1228
+ ],
1229
+ if (controller.activeRun != null ||
1230
+ controller.toolEvents.isNotEmpty) ...<Widget>[
1231
+ _RunStatusPanel(
1232
+ run: controller.activeRun,
1233
+ tools: controller.toolEvents,
1234
+ ),
1235
+ const SizedBox(height: 16),
1236
+ ],
1237
+ if (controller.recentRuns.isEmpty)
1238
+ const _EmptyCard(
1239
+ title: 'No runs yet',
1240
+ subtitle:
1241
+ 'Send a task from chat and its execution history will show up here.',
1242
+ )
1243
+ else ...<Widget>[
1244
+ _RunsMetricsStrip(
1245
+ runs: filteredRuns,
1246
+ totalLoaded: controller.recentRuns.length,
1247
+ ),
1248
+ const SizedBox(height: 16),
1249
+ _RunsFilterBar(
1250
+ searchController: _searchController,
1251
+ statusFilter: _statusFilter,
1252
+ onStatusChanged: _setStatusFilter,
1253
+ ),
1254
+ const SizedBox(height: 16),
1255
+ if (filteredRuns.isEmpty)
1256
+ const _EmptyCard(
1257
+ title: 'No matching runs',
1258
+ subtitle:
1259
+ 'Try clearing the search or switching the status filter.',
1260
+ )
1261
+ else
1262
+ LayoutBuilder(
1263
+ builder: (context, constraints) {
1264
+ final wide = constraints.maxWidth >= 1120;
1265
+ final historyPane = _RunsHistoryPane(
1266
+ runs: filteredRuns,
1267
+ selectedRunId: _selectedRunId,
1268
+ onSelect: _selectRun,
1269
+ );
1270
+ final detailPane = _RunDetailWorkspace(
1271
+ run: selected,
1272
+ detail: detail,
1273
+ errorMessage: _detailError,
1274
+ loading: _loadingDetail,
1275
+ onDelete: _deleteSelectedRun,
1276
+ onCopyResponse: _copyResponse,
1277
+ );
1278
+ if (!wide) {
1279
+ return Column(
1280
+ children: <Widget>[
1281
+ detailPane,
1282
+ const SizedBox(height: 16),
1283
+ historyPane,
1284
+ ],
1285
+ );
1286
+ }
1287
+ return Row(
1288
+ crossAxisAlignment: CrossAxisAlignment.start,
1289
+ children: <Widget>[
1290
+ SizedBox(width: 360, child: historyPane),
1291
+ const SizedBox(width: 16),
1292
+ Expanded(child: detailPane),
1293
+ ],
1294
+ );
1295
+ },
1296
+ ),
1297
+ ],
1298
+ ],
1299
+ );
1300
+ }
1301
+ }
1302
+
1303
+ class _MessagingCard extends StatelessWidget {
1304
+ const _MessagingCard({
1305
+ required this.platform,
1306
+ required this.status,
1307
+ required this.accessCatalog,
1308
+ required this.controller,
1309
+ required this.onConnect,
1310
+ required this.onDisconnect,
1311
+ required this.onLogout,
1312
+ });
1313
+
1314
+ final MessagingPlatformDescriptor platform;
1315
+ final MessagingPlatformStatus? status;
1316
+ final MessagingAccessCatalog accessCatalog;
1317
+ final NeoAgentController controller;
1318
+ final Future<void> Function() onConnect;
1319
+ final Future<void> Function() onDisconnect;
1320
+ final Future<void> Function() onLogout;
1321
+
1322
+ @override
1323
+ Widget build(BuildContext context) {
1324
+ final connected = status?.isConnected ?? false;
1325
+ final configured = status != null && status!.status != 'not_configured';
1326
+ final accent = platform.accent;
1327
+ final actionLabel = connected
1328
+ ? 'Connected'
1329
+ : configured
1330
+ ? 'Reconnect'
1331
+ : 'Connect';
1332
+ final accessLabel = accessCatalog.summary.ifEmpty('Access policy');
1333
+ return Container(
1334
+ padding: const EdgeInsets.all(16),
1335
+ decoration: BoxDecoration(
1336
+ color: _bgCard,
1337
+ borderRadius: BorderRadius.circular(8),
1338
+ border: Border.all(
1339
+ color: connected ? accent.withValues(alpha: 0.48) : _borderLight,
1340
+ ),
1341
+ boxShadow: [
1342
+ if (connected)
1343
+ BoxShadow(
1344
+ color: accent.withValues(alpha: 0.08),
1345
+ blurRadius: 18,
1346
+ offset: const Offset(0, 10),
1347
+ ),
1348
+ ],
1349
+ ),
1350
+ child: Column(
1351
+ crossAxisAlignment: CrossAxisAlignment.start,
1352
+ children: [
1353
+ Row(
1354
+ children: [
1355
+ Container(
1356
+ width: 44,
1357
+ height: 44,
1358
+ decoration: BoxDecoration(
1359
+ color: accent.withValues(alpha: 0.12),
1360
+ borderRadius: BorderRadius.circular(8),
1361
+ ),
1362
+ child: Icon(platform.icon, color: accent, size: 23),
1363
+ ),
1364
+ const SizedBox(width: 12),
1365
+ Expanded(
1366
+ child: Column(
1367
+ crossAxisAlignment: CrossAxisAlignment.start,
1368
+ children: [
1369
+ Text(
1370
+ platform.label,
1371
+ style: TextStyle(
1372
+ color: _textPrimary,
1373
+ fontWeight: FontWeight.w800,
1374
+ fontSize: 16,
1375
+ ),
1376
+ maxLines: 1,
1377
+ overflow: TextOverflow.ellipsis,
1378
+ ),
1379
+ const SizedBox(height: 3),
1380
+ Text(
1381
+ status?.authLabel ?? 'Not configured',
1382
+ style: TextStyle(color: _textSecondary, fontSize: 12),
1383
+ maxLines: 1,
1384
+ overflow: TextOverflow.ellipsis,
1385
+ ),
1386
+ ],
1387
+ ),
1388
+ ),
1389
+ const SizedBox(width: 10),
1390
+ _StatusPill(
1391
+ label: connected
1392
+ ? 'Live'
1393
+ : configured
1394
+ ? 'Ready'
1395
+ : 'Setup',
1396
+ color: connected
1397
+ ? _success
1398
+ : configured
1399
+ ? _warning
1400
+ : _textMuted,
1401
+ ),
1402
+ ],
1403
+ ),
1404
+ const SizedBox(height: 14),
1405
+ Text(
1406
+ platform.subtitle,
1407
+ style: TextStyle(color: _textSecondary, height: 1.4),
1408
+ maxLines: 2,
1409
+ overflow: TextOverflow.ellipsis,
1410
+ ),
1411
+ const Spacer(),
1412
+ Wrap(
1413
+ spacing: 8,
1414
+ runSpacing: 8,
1415
+ children: [
1416
+ _MessagingMiniPill(
1417
+ icon: Icons.admin_panel_settings_outlined,
1418
+ label: accessLabel,
1419
+ ),
1420
+ if (configured && !connected)
1421
+ const _MessagingMiniPill(
1422
+ icon: Icons.tune_rounded,
1423
+ label: 'Configured',
1424
+ ),
1425
+ if (platform.configFields.isNotEmpty)
1426
+ _MessagingMiniPill(
1427
+ icon: Icons.edit_note_rounded,
1428
+ label: '${platform.configFields.length} fields',
1429
+ ),
1430
+ ],
1431
+ ),
1432
+ const SizedBox(height: 14),
1433
+ Row(
1434
+ children: [
1435
+ Expanded(
1436
+ child: connected
1437
+ ? OutlinedButton.icon(
1438
+ onPressed: onDisconnect,
1439
+ icon: Icon(Icons.link_off_rounded, size: 18),
1440
+ label: Text(
1441
+ 'Disconnect',
1442
+ overflow: TextOverflow.ellipsis,
1443
+ ),
1444
+ )
1445
+ : FilledButton.icon(
1446
+ onPressed: onConnect,
1447
+ icon: Icon(Icons.power_settings_new_rounded, size: 18),
1448
+ label: Text(
1449
+ actionLabel,
1450
+ overflow: TextOverflow.ellipsis,
1451
+ ),
1452
+ style: FilledButton.styleFrom(backgroundColor: accent),
1453
+ ),
1454
+ ),
1455
+ const SizedBox(width: 8),
1456
+ IconButton.outlined(
1457
+ tooltip: 'Access policy',
1458
+ onPressed: () => _editAccessPolicy(context, controller),
1459
+ icon: Icon(Icons.group_add_outlined),
1460
+ ),
1461
+ if (platform.id == 'telnyx') ...[
1462
+ const SizedBox(width: 8),
1463
+ IconButton.outlined(
1464
+ tooltip: 'Voice PIN',
1465
+ onPressed: () => _editTelnyxSecret(context, controller),
1466
+ icon: Icon(Icons.password_outlined),
1467
+ ),
1468
+ ],
1469
+ if (connected) ...[
1470
+ const SizedBox(width: 8),
1471
+ IconButton.outlined(
1472
+ tooltip: 'Logout',
1473
+ onPressed: onLogout,
1474
+ icon: Icon(Icons.logout_rounded),
1475
+ ),
1476
+ ],
1477
+ ],
1478
+ ),
1479
+ ],
1480
+ ),
1481
+ );
1482
+ }
1483
+
1484
+ Future<void> _editAccessPolicy(
1485
+ BuildContext context,
1486
+ NeoAgentController controller,
1487
+ ) async {
1488
+ final catalog = await controller.loadMessagingAccessCatalog(
1489
+ platform.id,
1490
+ force: true,
1491
+ );
1492
+ if (!context.mounted) return;
1493
+ await _showMessagingAccessPolicyDialog(
1494
+ context,
1495
+ platform: platform,
1496
+ initialCatalog: catalog,
1497
+ onRefreshCatalog: () =>
1498
+ controller.loadMessagingAccessCatalog(platform.id, force: true),
1499
+ onSave: (policy) =>
1500
+ controller.saveMessagingAccessPolicy(platform.id, policy),
1501
+ );
1502
+ }
1503
+
1504
+ Future<void> _editTelnyxSecret(
1505
+ BuildContext context,
1506
+ NeoAgentController controller,
1507
+ ) async {
1508
+ final initial =
1509
+ controller.settings['platform_voice_secret_telnyx']?.toString() ?? '';
1510
+ final saved = await _showTextSettingDialog(
1511
+ context,
1512
+ title: 'Voice PIN',
1513
+ subtitle:
1514
+ 'Set the PIN callers must enter before the voice agent answers.',
1515
+ label: 'PIN or passphrase',
1516
+ initialValue: initial,
1517
+ obscureText: true,
1518
+ );
1519
+ if (saved != null) {
1520
+ await controller.saveTelnyxVoiceSecret(saved);
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ class _MessagingRuleSelection {
1526
+ const _MessagingRuleSelection({required this.bucket, required this.rule});
1527
+
1528
+ final String bucket;
1529
+ final MessagingAccessRule rule;
1530
+ }
1531
+
1532
+ Future<void> _showMessagingAccessPolicyDialog(
1533
+ BuildContext context, {
1534
+ required MessagingPlatformDescriptor platform,
1535
+ required MessagingAccessCatalog initialCatalog,
1536
+ required Future<MessagingAccessCatalog> Function() onRefreshCatalog,
1537
+ required Future<void> Function(MessagingAccessPolicy policy) onSave,
1538
+ }) async {
1539
+ var catalog = initialCatalog;
1540
+ var policy = initialCatalog.policy;
1541
+
1542
+ List<MessagingAccessRule> dedupeRules(List<MessagingAccessRule> rules) {
1543
+ final seen = <String>{};
1544
+ final result = <MessagingAccessRule>[];
1545
+ for (final rule in rules) {
1546
+ if (rule.value.trim().isEmpty) continue;
1547
+ if (!seen.add(rule.id)) continue;
1548
+ result.add(rule);
1549
+ }
1550
+ return result;
1551
+ }
1552
+
1553
+ void addRule(
1554
+ _MessagingRuleSelection selection,
1555
+ void Function(void Function()) setLocalState,
1556
+ ) {
1557
+ setLocalState(() {
1558
+ switch (selection.bucket) {
1559
+ case 'directRules':
1560
+ policy = policy.copyWith(
1561
+ directPolicy: policy.directPolicy == 'disabled'
1562
+ ? 'allowlist'
1563
+ : policy.directPolicy,
1564
+ directRules: dedupeRules(<MessagingAccessRule>[
1565
+ ...policy.directRules,
1566
+ selection.rule,
1567
+ ]),
1568
+ );
1569
+ break;
1570
+ case 'sharedActorRules':
1571
+ policy = policy.copyWith(
1572
+ sharedPolicy: policy.sharedPolicy == 'disabled'
1573
+ ? 'allowlist'
1574
+ : policy.sharedPolicy,
1575
+ sharedActorRules: dedupeRules(<MessagingAccessRule>[
1576
+ ...policy.sharedActorRules,
1577
+ selection.rule,
1578
+ ]),
1579
+ );
1580
+ break;
1581
+ default:
1582
+ policy = policy.copyWith(
1583
+ sharedPolicy: policy.sharedPolicy == 'disabled'
1584
+ ? 'allowlist'
1585
+ : policy.sharedPolicy,
1586
+ sharedSpaceRules: dedupeRules(<MessagingAccessRule>[
1587
+ ...policy.sharedSpaceRules,
1588
+ selection.rule,
1589
+ ]),
1590
+ );
1591
+ }
1592
+ });
1593
+ }
1594
+
1595
+ void removeRule(
1596
+ String bucket,
1597
+ MessagingAccessRule rule,
1598
+ void Function(void Function()) setLocalState,
1599
+ ) {
1600
+ setLocalState(() {
1601
+ switch (bucket) {
1602
+ case 'directRules':
1603
+ policy = policy.copyWith(
1604
+ directRules: policy.directRules
1605
+ .where((item) => item.id != rule.id)
1606
+ .toList(growable: false),
1607
+ );
1608
+ break;
1609
+ case 'sharedActorRules':
1610
+ policy = policy.copyWith(
1611
+ sharedActorRules: policy.sharedActorRules
1612
+ .where((item) => item.id != rule.id)
1613
+ .toList(growable: false),
1614
+ );
1615
+ break;
1616
+ default:
1617
+ policy = policy.copyWith(
1618
+ sharedSpaceRules: policy.sharedSpaceRules
1619
+ .where((item) => item.id != rule.id)
1620
+ .toList(growable: false),
1621
+ );
1622
+ }
1623
+ });
1624
+ }
1625
+
1626
+ await showDialog<void>(
1627
+ context: context,
1628
+ builder: (dialogContext) {
1629
+ return StatefulBuilder(
1630
+ builder: (context, setLocalState) {
1631
+ final capabilities = catalog.capabilities;
1632
+ final summaryText = [
1633
+ 'DMs ${policy.directPolicy}',
1634
+ if (capabilities.supportsSharedPolicy)
1635
+ 'shared ${policy.sharedPolicy}',
1636
+ if (capabilities.supportsMentionGate)
1637
+ policy.requireMentionInShared
1638
+ ? 'mentions required'
1639
+ : 'mentions optional',
1640
+ if (policy.totalRuleCount > 0) '${policy.totalRuleCount} rules',
1641
+ ].join(' • ');
1642
+
1643
+ return AlertDialog(
1644
+ backgroundColor: _bgCard,
1645
+ insetPadding: const EdgeInsets.symmetric(
1646
+ horizontal: 24,
1647
+ vertical: 18,
1648
+ ),
1649
+ title: Text('${platform.label} Access Policy'),
1650
+ content: SizedBox(
1651
+ width: 760,
1652
+ child: SingleChildScrollView(
1653
+ child: Column(
1654
+ mainAxisSize: MainAxisSize.min,
1655
+ crossAxisAlignment: CrossAxisAlignment.start,
1656
+ children: <Widget>[
1657
+ MessagingAccessSummaryCard(
1658
+ accent: platform.accent,
1659
+ summary: summaryText,
1660
+ hint: capabilities.manualEntryHint.ifEmpty(
1661
+ 'Choose who can reach this platform and how shared spaces behave.',
1662
+ ),
1663
+ ),
1664
+ const SizedBox(height: 18),
1665
+ if (capabilities.supportsDirectPolicy)
1666
+ _AccessModeField(
1667
+ label: 'Direct messages',
1668
+ value: policy.directPolicy,
1669
+ onChanged: (value) => setLocalState(() {
1670
+ policy = policy.copyWith(directPolicy: value);
1671
+ }),
1672
+ ),
1673
+ if (capabilities.supportsSharedPolicy) ...<Widget>[
1674
+ const SizedBox(height: 12),
1675
+ _AccessModeField(
1676
+ label: 'Shared spaces',
1677
+ value: policy.sharedPolicy,
1678
+ onChanged: (value) => setLocalState(() {
1679
+ policy = policy.copyWith(sharedPolicy: value);
1680
+ }),
1681
+ ),
1682
+ ],
1683
+ if (capabilities.supportsMentionGate) ...<Widget>[
1684
+ const SizedBox(height: 12),
1685
+ SwitchListTile(
1686
+ contentPadding: EdgeInsets.zero,
1687
+ title: Text('Require mention in shared spaces'),
1688
+ subtitle: Text(
1689
+ 'Keep channels quiet until the bot is directly mentioned.',
1690
+ style: TextStyle(color: _textSecondary),
1691
+ ),
1692
+ value: policy.requireMentionInShared,
1693
+ onChanged: (value) => setLocalState(() {
1694
+ policy = policy.copyWith(
1695
+ requireMentionInShared: value,
1696
+ );
1697
+ }),
1698
+ ),
1699
+ ],
1700
+ const SizedBox(height: 14),
1701
+ Row(
1702
+ children: <Widget>[
1703
+ FilledButton.icon(
1704
+ onPressed: () async {
1705
+ final selection =
1706
+ await _showMessagingAccessRulePicker(
1707
+ context,
1708
+ platform: platform,
1709
+ catalog: catalog,
1710
+ );
1711
+ if (selection != null) {
1712
+ addRule(selection, setLocalState);
1713
+ }
1714
+ },
1715
+ icon: Icon(Icons.add_rounded),
1716
+ label: Text('Add Rule'),
1717
+ ),
1718
+ const SizedBox(width: 10),
1719
+ OutlinedButton.icon(
1720
+ onPressed: () async {
1721
+ final refreshed = await onRefreshCatalog();
1722
+ if (!context.mounted) return;
1723
+ setLocalState(() {
1724
+ catalog = refreshed;
1725
+ });
1726
+ },
1727
+ icon: Icon(Icons.travel_explore_rounded),
1728
+ label: Text('Refresh Discovery'),
1729
+ ),
1730
+ ],
1731
+ ),
1732
+ const SizedBox(height: 18),
1733
+ _AccessRuleSection(
1734
+ title: 'Direct senders',
1735
+ subtitle: 'Who can start a one-to-one conversation.',
1736
+ rules: policy.directRules,
1737
+ emptyLabel: 'No direct sender rules yet.',
1738
+ onRemove: (rule) =>
1739
+ removeRule('directRules', rule, setLocalState),
1740
+ ),
1741
+ if (capabilities.supportsSharedPolicy) ...<Widget>[
1742
+ const SizedBox(height: 16),
1743
+ _AccessRuleSection(
1744
+ title: 'Shared spaces',
1745
+ subtitle:
1746
+ 'Which channels, groups, rooms, or servers can trigger the agent.',
1747
+ rules: policy.sharedSpaceRules,
1748
+ emptyLabel: 'No shared-space rules yet.',
1749
+ onRemove: (rule) =>
1750
+ removeRule('sharedSpaceRules', rule, setLocalState),
1751
+ ),
1752
+ const SizedBox(height: 16),
1753
+ _AccessRuleSection(
1754
+ title: 'Shared actors',
1755
+ subtitle:
1756
+ 'Optional extra filter for who inside allowed shared spaces can trigger the agent.',
1757
+ rules: policy.sharedActorRules,
1758
+ emptyLabel: 'No shared-actor rules yet.',
1759
+ onRemove: (rule) =>
1760
+ removeRule('sharedActorRules', rule, setLocalState),
1761
+ ),
1762
+ ],
1763
+ ],
1764
+ ),
1765
+ ),
1766
+ ),
1767
+ actions: <Widget>[
1768
+ TextButton(
1769
+ onPressed: () => Navigator.of(dialogContext).pop(),
1770
+ child: Text('Cancel'),
1771
+ ),
1772
+ FilledButton(
1773
+ onPressed: () async {
1774
+ await onSave(policy);
1775
+ if (dialogContext.mounted) {
1776
+ Navigator.of(dialogContext).pop();
1777
+ }
1778
+ },
1779
+ child: Text('Save Policy'),
1780
+ ),
1781
+ ],
1782
+ );
1783
+ },
1784
+ );
1785
+ },
1786
+ );
1787
+ }
1788
+
1789
+ class _AccessModeField extends StatelessWidget {
1790
+ const _AccessModeField({
1791
+ required this.label,
1792
+ required this.value,
1793
+ required this.onChanged,
1794
+ });
1795
+
1796
+ final String label;
1797
+ final String value;
1798
+ final ValueChanged<String> onChanged;
1799
+
1800
+ @override
1801
+ Widget build(BuildContext context) {
1802
+ return InputDecorator(
1803
+ decoration: InputDecoration(labelText: label),
1804
+ child: DropdownButtonHideUnderline(
1805
+ child: DropdownButton<String>(
1806
+ value: value,
1807
+ isExpanded: true,
1808
+ items: const <DropdownMenuItem<String>>[
1809
+ DropdownMenuItem(value: 'allowlist', child: Text('Allowlist only')),
1810
+ DropdownMenuItem(value: 'open', child: Text('Open access')),
1811
+ DropdownMenuItem(value: 'disabled', child: Text('Disabled')),
1812
+ ],
1813
+ onChanged: (next) {
1814
+ if (next != null) onChanged(next);
1815
+ },
1816
+ ),
1817
+ ),
1818
+ );
1819
+ }
1820
+ }
1821
+
1822
+ class _AccessRuleSection extends StatelessWidget {
1823
+ const _AccessRuleSection({
1824
+ required this.title,
1825
+ required this.subtitle,
1826
+ required this.rules,
1827
+ required this.emptyLabel,
1828
+ required this.onRemove,
1829
+ });
1830
+
1831
+ final String title;
1832
+ final String subtitle;
1833
+ final List<MessagingAccessRule> rules;
1834
+ final String emptyLabel;
1835
+ final ValueChanged<MessagingAccessRule> onRemove;
1836
+
1837
+ @override
1838
+ Widget build(BuildContext context) {
1839
+ return Container(
1840
+ width: double.infinity,
1841
+ padding: const EdgeInsets.all(14),
1842
+ decoration: BoxDecoration(
1843
+ color: _bgCard,
1844
+ borderRadius: BorderRadius.circular(16),
1845
+ border: Border.all(color: _borderLight),
1846
+ ),
1847
+ child: Column(
1848
+ crossAxisAlignment: CrossAxisAlignment.start,
1849
+ children: <Widget>[
1850
+ Text(title, style: TextStyle(fontWeight: FontWeight.w700)),
1851
+ const SizedBox(height: 4),
1852
+ Text(subtitle, style: TextStyle(color: _textSecondary)),
1853
+ const SizedBox(height: 12),
1854
+ if (rules.isEmpty)
1855
+ Text(emptyLabel, style: TextStyle(color: _textMuted))
1856
+ else
1857
+ Wrap(
1858
+ spacing: 8,
1859
+ runSpacing: 8,
1860
+ children: rules
1861
+ .map((rule) {
1862
+ return Chip(
1863
+ label: Text('${rule.scopeLabel}: ${rule.displayLabel}'),
1864
+ deleteIcon: Icon(Icons.close_rounded, size: 18),
1865
+ onDeleted: () => onRemove(rule),
1866
+ );
1867
+ })
1868
+ .toList(growable: false),
1869
+ ),
1870
+ ],
1871
+ ),
1872
+ );
1873
+ }
1874
+ }
1875
+
1876
+ Future<_MessagingRuleSelection?> _showMessagingAccessRulePicker(
1877
+ BuildContext context, {
1878
+ required MessagingPlatformDescriptor platform,
1879
+ required MessagingAccessCatalog catalog,
1880
+ }) async {
1881
+ return showModalBottomSheet<_MessagingRuleSelection>(
1882
+ context: context,
1883
+ isScrollControlled: true,
1884
+ backgroundColor: _bgCard,
1885
+ builder: (sheetContext) =>
1886
+ _MessagingAccessRulePickerSheet(platform: platform, catalog: catalog),
1887
+ );
1888
+ }
1889
+
1890
+ class _MessagingAccessRulePickerSheet extends StatefulWidget {
1891
+ const _MessagingAccessRulePickerSheet({
1892
+ required this.platform,
1893
+ required this.catalog,
1894
+ });
1895
+
1896
+ final MessagingPlatformDescriptor platform;
1897
+ final MessagingAccessCatalog catalog;
1898
+
1899
+ @override
1900
+ State<_MessagingAccessRulePickerSheet> createState() =>
1901
+ _MessagingAccessRulePickerSheetState();
1902
+ }
1903
+
1904
+ class _MessagingAccessRulePickerSheetState
1905
+ extends State<_MessagingAccessRulePickerSheet> {
1906
+ late final TextEditingController _queryController;
1907
+ late String _selectedBucket;
1908
+ late String _selectedScope;
1909
+
1910
+ @override
1911
+ void initState() {
1912
+ super.initState();
1913
+ _queryController = TextEditingController();
1914
+ _selectedBucket = widget.catalog.capabilities.directRuleScopes.isNotEmpty
1915
+ ? 'directRules'
1916
+ : (widget.catalog.capabilities.sharedSpaceRuleScopes.isNotEmpty
1917
+ ? 'sharedSpaceRules'
1918
+ : 'sharedActorRules');
1919
+ _selectedScope = widget.catalog.capabilities.directRuleScopes.isNotEmpty
1920
+ ? widget.catalog.capabilities.directRuleScopes.first
1921
+ : (widget.catalog.capabilities.sharedSpaceRuleScopes.isNotEmpty
1922
+ ? widget.catalog.capabilities.sharedSpaceRuleScopes.first
1923
+ : (widget.catalog.capabilities.sharedActorRuleScopes.isNotEmpty
1924
+ ? widget.catalog.capabilities.sharedActorRuleScopes.first
1925
+ : 'chat'));
1926
+ }
1927
+
1928
+ @override
1929
+ void dispose() {
1930
+ _queryController.dispose();
1931
+ super.dispose();
1932
+ }
1933
+
1934
+ @override
1935
+ void didUpdateWidget(covariant _MessagingAccessRulePickerSheet oldWidget) {
1936
+ super.didUpdateWidget(oldWidget);
1937
+ _syncSelectedScope();
1938
+ }
1939
+
1940
+ List<String> _scopesForBucket() {
1941
+ switch (_selectedBucket) {
1942
+ case 'directRules':
1943
+ return widget.catalog.capabilities.directRuleScopes;
1944
+ case 'sharedActorRules':
1945
+ return widget.catalog.capabilities.sharedActorRuleScopes;
1946
+ default:
1947
+ return widget.catalog.capabilities.sharedSpaceRuleScopes;
1948
+ }
1949
+ }
1950
+
1951
+ void _syncSelectedScope() {
1952
+ final availableScopes = _scopesForBucket();
1953
+ if (availableScopes.isEmpty || availableScopes.contains(_selectedScope)) {
1954
+ return;
1955
+ }
1956
+ _selectedScope = availableScopes.first;
1957
+ }
1958
+
1959
+ @override
1960
+ Widget build(BuildContext context) {
1961
+ final availableScopes = _scopesForBucket();
1962
+ final query = _queryController.text.trim().toLowerCase();
1963
+ final targets =
1964
+ <MessagingAccessTarget>[
1965
+ ...widget.catalog.suggestedTargets,
1966
+ ...widget.catalog.discoveredTargets,
1967
+ ]
1968
+ .where((target) {
1969
+ if (target.bucket != _selectedBucket) return false;
1970
+ if (query.isEmpty) return true;
1971
+ final haystack =
1972
+ '${target.label} ${target.subtitle} ${target.scope} ${target.value}'
1973
+ .toLowerCase();
1974
+ return haystack.contains(query);
1975
+ })
1976
+ .toList(growable: false);
1977
+
1978
+ return Padding(
1979
+ padding: EdgeInsets.only(
1980
+ left: 20,
1981
+ right: 20,
1982
+ top: 18,
1983
+ bottom: MediaQuery.of(context).viewInsets.bottom + 20,
1984
+ ),
1985
+ child: SingleChildScrollView(
1986
+ child: Column(
1987
+ mainAxisSize: MainAxisSize.min,
1988
+ crossAxisAlignment: CrossAxisAlignment.start,
1989
+ children: <Widget>[
1990
+ Text(
1991
+ 'Add Access Rule',
1992
+ style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
1993
+ ),
1994
+ const SizedBox(height: 6),
1995
+ Text(
1996
+ 'Choose a preset, a discovered target, or enter an id manually for ${widget.platform.label}.',
1997
+ style: TextStyle(color: _textSecondary),
1998
+ ),
1999
+ const SizedBox(height: 14),
2000
+ Wrap(
2001
+ spacing: 8,
2002
+ runSpacing: 8,
2003
+ children: <Widget>[
2004
+ if (widget.catalog.capabilities.directRuleScopes.isNotEmpty)
2005
+ ChoiceChip(
2006
+ label: Text('Direct'),
2007
+ selected: _selectedBucket == 'directRules',
2008
+ onSelected: (_) => setState(() {
2009
+ _selectedBucket = 'directRules';
2010
+ _syncSelectedScope();
2011
+ }),
2012
+ ),
2013
+ if (widget
2014
+ .catalog
2015
+ .capabilities
2016
+ .sharedSpaceRuleScopes
2017
+ .isNotEmpty)
2018
+ ChoiceChip(
2019
+ label: Text('Shared spaces'),
2020
+ selected: _selectedBucket == 'sharedSpaceRules',
2021
+ onSelected: (_) => setState(() {
2022
+ _selectedBucket = 'sharedSpaceRules';
2023
+ _syncSelectedScope();
2024
+ }),
2025
+ ),
2026
+ if (widget
2027
+ .catalog
2028
+ .capabilities
2029
+ .sharedActorRuleScopes
2030
+ .isNotEmpty)
2031
+ ChoiceChip(
2032
+ label: Text('Shared actors'),
2033
+ selected: _selectedBucket == 'sharedActorRules',
2034
+ onSelected: (_) => setState(() {
2035
+ _selectedBucket = 'sharedActorRules';
2036
+ _syncSelectedScope();
2037
+ }),
2038
+ ),
2039
+ ],
2040
+ ),
2041
+ const SizedBox(height: 12),
2042
+ TextField(
2043
+ controller: _queryController,
2044
+ onChanged: (_) => setState(() {}),
2045
+ decoration: InputDecoration(
2046
+ prefixIcon: Icon(Icons.search_rounded),
2047
+ labelText: 'Search discovered targets',
2048
+ ),
2049
+ ),
2050
+ const SizedBox(height: 16),
2051
+ if (targets.isNotEmpty) ...<Widget>[
2052
+ Text(
2053
+ 'Suggested & discovered',
2054
+ style: TextStyle(fontWeight: FontWeight.w700),
2055
+ ),
2056
+ const SizedBox(height: 8),
2057
+ ...targets.take(10).map((target) {
2058
+ return ListTile(
2059
+ contentPadding: EdgeInsets.zero,
2060
+ title: Text(target.label),
2061
+ subtitle: Text(
2062
+ target.subtitle.ifEmpty(
2063
+ '${target.scope} • ${target.value}',
2064
+ ),
2065
+ ),
2066
+ trailing: Icon(Icons.add_circle_outline_rounded),
2067
+ onTap: () => Navigator.of(context).pop(
2068
+ _MessagingRuleSelection(
2069
+ bucket: target.bucket,
2070
+ rule: target.asRule,
2071
+ ),
2072
+ ),
2073
+ );
2074
+ }),
2075
+ const Divider(height: 24),
2076
+ ],
2077
+ Text('Manual entry', style: TextStyle(fontWeight: FontWeight.w700)),
2078
+ const SizedBox(height: 8),
2079
+ if (availableScopes.isNotEmpty)
2080
+ InputDecorator(
2081
+ decoration: InputDecoration(labelText: 'Rule scope'),
2082
+ child: DropdownButtonHideUnderline(
2083
+ child: DropdownButton<String>(
2084
+ value: _selectedScope,
2085
+ isExpanded: true,
2086
+ items: availableScopes
2087
+ .map(
2088
+ (scope) => DropdownMenuItem<String>(
2089
+ value: scope,
2090
+ child: Text(scope.replaceAll('_', ' ')),
2091
+ ),
2092
+ )
2093
+ .toList(growable: false),
2094
+ onChanged: (value) {
2095
+ if (value != null) {
2096
+ setState(() => _selectedScope = value);
2097
+ }
2098
+ },
2099
+ ),
2100
+ ),
2101
+ ),
2102
+ const SizedBox(height: 12),
2103
+ TextField(
2104
+ decoration: InputDecoration(
2105
+ labelText: 'ID / value',
2106
+ helperText: widget.catalog.capabilities.manualEntryHint,
2107
+ ),
2108
+ onSubmitted: (value) {
2109
+ final trimmed = value.trim();
2110
+ if (trimmed.isEmpty) return;
2111
+ Navigator.of(context).pop(
2112
+ _MessagingRuleSelection(
2113
+ bucket: _selectedBucket,
2114
+ rule: MessagingAccessRule(
2115
+ scope: _selectedScope,
2116
+ value: trimmed,
2117
+ ),
2118
+ ),
2119
+ );
2120
+ },
2121
+ ),
2122
+ ],
2123
+ ),
2124
+ ),
2125
+ );
2126
+ }
2127
+ }
2128
+
2129
+ Future<String?> _showTextSettingDialog(
2130
+ BuildContext context, {
2131
+ required String title,
2132
+ required String subtitle,
2133
+ required String label,
2134
+ required String initialValue,
2135
+ bool obscureText = false,
2136
+ }) async {
2137
+ final controller = TextEditingController(text: initialValue);
2138
+ try {
2139
+ return showDialog<String>(
2140
+ context: context,
2141
+ builder: (context) => AlertDialog(
2142
+ backgroundColor: _bgCard,
2143
+ title: Text(title),
2144
+ content: SizedBox(
2145
+ width: 440,
2146
+ child: Column(
2147
+ mainAxisSize: MainAxisSize.min,
2148
+ crossAxisAlignment: CrossAxisAlignment.start,
2149
+ children: <Widget>[
2150
+ Text(subtitle, style: TextStyle(color: _textSecondary)),
2151
+ const SizedBox(height: 14),
2152
+ TextField(
2153
+ controller: controller,
2154
+ obscureText: obscureText,
2155
+ decoration: InputDecoration(labelText: label),
2156
+ ),
2157
+ ],
2158
+ ),
2159
+ ),
2160
+ actions: <Widget>[
2161
+ TextButton(
2162
+ onPressed: () => Navigator.of(context).pop(),
2163
+ child: Text('Cancel'),
2164
+ ),
2165
+ FilledButton(
2166
+ onPressed: () => Navigator.of(context).pop(controller.text.trim()),
2167
+ child: Text('Save'),
2168
+ ),
2169
+ ],
2170
+ ),
2171
+ );
2172
+ } finally {
2173
+ controller.dispose();
2174
+ }
2175
+ }
2176
+
2177
+ class _MessagingMiniPill extends StatelessWidget {
2178
+ const _MessagingMiniPill({required this.icon, required this.label});
2179
+
2180
+ final IconData icon;
2181
+ final String label;
2182
+
2183
+ @override
2184
+ Widget build(BuildContext context) {
2185
+ return Container(
2186
+ padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6),
2187
+ decoration: BoxDecoration(
2188
+ color: _bgSecondary,
2189
+ borderRadius: BorderRadius.circular(8),
2190
+ border: Border.all(color: _borderLight),
2191
+ ),
2192
+ child: ConstrainedBox(
2193
+ constraints: const BoxConstraints(maxWidth: 260),
2194
+ child: Row(
2195
+ mainAxisSize: MainAxisSize.min,
2196
+ children: [
2197
+ Icon(icon, size: 14, color: _textSecondary),
2198
+ const SizedBox(width: 6),
2199
+ Flexible(
2200
+ child: Text(
2201
+ label,
2202
+ maxLines: 2,
2203
+ overflow: TextOverflow.ellipsis,
2204
+ style: TextStyle(
2205
+ color: _textSecondary,
2206
+ fontSize: 12,
2207
+ fontWeight: FontWeight.w600,
2208
+ ),
2209
+ ),
2210
+ ),
2211
+ ],
2212
+ ),
2213
+ ),
2214
+ );
2215
+ }
2216
+ }
2217
+
2218
+ class _RunsMetricsStrip extends StatelessWidget {
2219
+ const _RunsMetricsStrip({required this.runs, required this.totalLoaded});
2220
+
2221
+ final List<RunSummary> runs;
2222
+ final int totalLoaded;
2223
+
2224
+ @override
2225
+ Widget build(BuildContext context) {
2226
+ final running = runs.where((run) => run.status == 'running').length;
2227
+ final failed = runs.where((run) => run.isFailure).length;
2228
+ final completed = runs.where((run) => run.status == 'completed').length;
2229
+ final tokens = runs.fold<int>(0, (sum, run) => sum + run.totalTokens);
2230
+
2231
+ return Wrap(
2232
+ spacing: 12,
2233
+ runSpacing: 12,
2234
+ children: <Widget>[
2235
+ _RunMetricCard(
2236
+ title: 'Showing',
2237
+ value: '${runs.length}',
2238
+ helper: totalLoaded == runs.length
2239
+ ? 'Recent runs loaded'
2240
+ : 'Filtered from $totalLoaded loaded runs',
2241
+ color: _info,
2242
+ ),
2243
+ _RunMetricCard(
2244
+ title: 'Completed',
2245
+ value: '$completed',
2246
+ helper: 'Finished successfully',
2247
+ color: _success,
2248
+ ),
2249
+ _RunMetricCard(
2250
+ title: 'Failed',
2251
+ value: '$failed',
2252
+ helper: 'Need attention',
2253
+ color: _danger,
2254
+ ),
2255
+ _RunMetricCard(
2256
+ title: 'Tokens',
2257
+ value: _formatNumber(tokens),
2258
+ helper: 'Across visible runs',
2259
+ color: _accentHover,
2260
+ ),
2261
+ if (running > 0)
2262
+ _RunMetricCard(
2263
+ title: 'Running',
2264
+ value: '$running',
2265
+ helper: 'Still in progress',
2266
+ color: _warning,
2267
+ ),
2268
+ ],
2269
+ );
2270
+ }
2271
+ }
2272
+
2273
+ class _RunMetricCard extends StatelessWidget {
2274
+ const _RunMetricCard({
2275
+ required this.title,
2276
+ required this.value,
2277
+ required this.helper,
2278
+ required this.color,
2279
+ });
2280
+
2281
+ final String title;
2282
+ final String value;
2283
+ final String helper;
2284
+ final Color color;
2285
+
2286
+ @override
2287
+ Widget build(BuildContext context) {
2288
+ return Container(
2289
+ constraints: const BoxConstraints(minWidth: 180, maxWidth: 220),
2290
+ padding: const EdgeInsets.all(16),
2291
+ decoration: BoxDecoration(
2292
+ color: _bgCard,
2293
+ borderRadius: BorderRadius.circular(18),
2294
+ border: Border.all(color: _border),
2295
+ boxShadow: <BoxShadow>[
2296
+ BoxShadow(
2297
+ color: color.withValues(alpha: 0.08),
2298
+ blurRadius: 18,
2299
+ offset: const Offset(0, 6),
2300
+ ),
2301
+ ],
2302
+ ),
2303
+ child: Column(
2304
+ crossAxisAlignment: CrossAxisAlignment.start,
2305
+ children: <Widget>[
2306
+ Text(title, style: TextStyle(color: _textSecondary)),
2307
+ const SizedBox(height: 8),
2308
+ Text(
2309
+ value,
2310
+ style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800),
2311
+ ),
2312
+ const SizedBox(height: 8),
2313
+ Text(helper, style: TextStyle(color: _textSecondary)),
2314
+ ],
2315
+ ),
2316
+ );
2317
+ }
2318
+ }
2319
+
2320
+ class _RunsFilterBar extends StatelessWidget {
2321
+ const _RunsFilterBar({
2322
+ required this.searchController,
2323
+ required this.statusFilter,
2324
+ required this.onStatusChanged,
2325
+ });
2326
+
2327
+ final TextEditingController searchController;
2328
+ final String statusFilter;
2329
+ final ValueChanged<String> onStatusChanged;
2330
+
2331
+ @override
2332
+ Widget build(BuildContext context) {
2333
+ const filters = <String>['all', 'running', 'completed', 'failed'];
2334
+ return Card(
2335
+ child: Padding(
2336
+ padding: const EdgeInsets.all(18),
2337
+ child: Column(
2338
+ crossAxisAlignment: CrossAxisAlignment.start,
2339
+ children: <Widget>[
2340
+ const _SectionTitle('Filter Runs'),
2341
+ const SizedBox(height: 12),
2342
+ TextField(
2343
+ controller: searchController,
2344
+ decoration: InputDecoration(
2345
+ prefixIcon: Icon(Icons.search),
2346
+ hintText: 'Search title, model, trigger, error, or run id',
2347
+ suffixIcon: searchController.text.trim().isEmpty
2348
+ ? null
2349
+ : IconButton(
2350
+ onPressed: searchController.clear,
2351
+ icon: Icon(Icons.close),
2352
+ ),
2353
+ ),
2354
+ ),
2355
+ const SizedBox(height: 14),
2356
+ Wrap(
2357
+ spacing: 10,
2358
+ runSpacing: 10,
2359
+ children: filters.map((filter) {
2360
+ return FilterChip(
2361
+ label: Text(_titleCase(filter)),
2362
+ selected: statusFilter == filter,
2363
+ selectedColor: _accentMuted,
2364
+ checkmarkColor: _accent,
2365
+ backgroundColor: _bgSecondary,
2366
+ side: BorderSide(color: _border),
2367
+ onSelected: (_) => onStatusChanged(filter),
2368
+ );
2369
+ }).toList(),
2370
+ ),
2371
+ ],
2372
+ ),
2373
+ ),
2374
+ );
2375
+ }
2376
+ }
2377
+
2378
+ class _RunsHistoryPane extends StatelessWidget {
2379
+ const _RunsHistoryPane({
2380
+ required this.runs,
2381
+ required this.selectedRunId,
2382
+ required this.onSelect,
2383
+ });
2384
+
2385
+ final List<RunSummary> runs;
2386
+ final String? selectedRunId;
2387
+ final ValueChanged<String> onSelect;
2388
+
2389
+ @override
2390
+ Widget build(BuildContext context) {
2391
+ return Card(
2392
+ child: Padding(
2393
+ padding: const EdgeInsets.all(18),
2394
+ child: Column(
2395
+ crossAxisAlignment: CrossAxisAlignment.start,
2396
+ children: <Widget>[
2397
+ Row(
2398
+ children: <Widget>[
2399
+ Expanded(child: _SectionTitle('Run History')),
2400
+ Text(
2401
+ '${runs.length} items',
2402
+ style: TextStyle(color: _textSecondary),
2403
+ ),
2404
+ ],
2405
+ ),
2406
+ const SizedBox(height: 12),
2407
+ ...runs.map((run) {
2408
+ return Padding(
2409
+ padding: const EdgeInsets.only(bottom: 10),
2410
+ child: _RunHistoryRow(
2411
+ run: run,
2412
+ selected: run.id == selectedRunId,
2413
+ onTap: () => onSelect(run.id),
2414
+ ),
2415
+ );
2416
+ }),
2417
+ ],
2418
+ ),
2419
+ ),
2420
+ );
2421
+ }
2422
+ }
2423
+
2424
+ class _RunHistoryRow extends StatelessWidget {
2425
+ const _RunHistoryRow({
2426
+ required this.run,
2427
+ required this.selected,
2428
+ required this.onTap,
2429
+ });
2430
+
2431
+ final RunSummary run;
2432
+ final bool selected;
2433
+ final VoidCallback onTap;
2434
+
2435
+ @override
2436
+ Widget build(BuildContext context) {
2437
+ return InkWell(
2438
+ borderRadius: BorderRadius.circular(16),
2439
+ onTap: onTap,
2440
+ child: Container(
2441
+ padding: const EdgeInsets.all(14),
2442
+ decoration: BoxDecoration(
2443
+ color: selected ? _accentMuted : _bgSecondary,
2444
+ borderRadius: BorderRadius.circular(16),
2445
+ border: Border.all(color: selected ? _accent : _border),
2446
+ ),
2447
+ child: Row(
2448
+ crossAxisAlignment: CrossAxisAlignment.start,
2449
+ children: <Widget>[
2450
+ Container(
2451
+ width: 12,
2452
+ height: 12,
2453
+ margin: const EdgeInsets.only(top: 5),
2454
+ decoration: BoxDecoration(
2455
+ color: run.statusColor,
2456
+ shape: BoxShape.circle,
2457
+ ),
2458
+ ),
2459
+ const SizedBox(width: 12),
2460
+ Expanded(
2461
+ child: Column(
2462
+ crossAxisAlignment: CrossAxisAlignment.start,
2463
+ children: <Widget>[
2464
+ Text(
2465
+ run.title,
2466
+ maxLines: 2,
2467
+ overflow: TextOverflow.ellipsis,
2468
+ style: TextStyle(fontWeight: FontWeight.w700, height: 1.2),
2469
+ ),
2470
+ const SizedBox(height: 6),
2471
+ Text(
2472
+ '${run.triggerLabel} • ${run.createdAtLabel}${run.durationLabel == 'In progress' ? '' : ' • ${run.durationLabel}'}',
2473
+ style: TextStyle(color: _textSecondary, fontSize: 12),
2474
+ ),
2475
+ const SizedBox(height: 4),
2476
+ Text(
2477
+ '${run.modelLabel} • ${run.totalTokensLabel} tokens',
2478
+ style: TextStyle(color: _textSecondary, fontSize: 12),
2479
+ ),
2480
+ if (run.error.trim().isNotEmpty) ...<Widget>[
2481
+ const SizedBox(height: 8),
2482
+ Text(
2483
+ run.error,
2484
+ maxLines: 2,
2485
+ overflow: TextOverflow.ellipsis,
2486
+ style: TextStyle(
2487
+ color: _danger,
2488
+ fontSize: 12,
2489
+ height: 1.4,
2490
+ ),
2491
+ ),
2492
+ ],
2493
+ ],
2494
+ ),
2495
+ ),
2496
+ const SizedBox(width: 10),
2497
+ Column(
2498
+ crossAxisAlignment: CrossAxisAlignment.end,
2499
+ children: <Widget>[
2500
+ _StatusPill(label: run.statusLabel, color: run.statusColor),
2501
+ const SizedBox(height: 12),
2502
+ Icon(
2503
+ Icons.chevron_right,
2504
+ color: selected ? _textPrimary : _textSecondary,
2505
+ ),
2506
+ ],
2507
+ ),
2508
+ ],
2509
+ ),
2510
+ ),
2511
+ );
2512
+ }
2513
+ }
2514
+
2515
+ class _RunDetailWorkspace extends StatelessWidget {
2516
+ const _RunDetailWorkspace({
2517
+ required this.run,
2518
+ required this.detail,
2519
+ required this.errorMessage,
2520
+ required this.loading,
2521
+ required this.onDelete,
2522
+ required this.onCopyResponse,
2523
+ });
2524
+
2525
+ final RunSummary? run;
2526
+ final RunDetailSnapshot? detail;
2527
+ final String? errorMessage;
2528
+ final bool loading;
2529
+ final Future<void> Function() onDelete;
2530
+ final Future<void> Function(String response) onCopyResponse;
2531
+
2532
+ @override
2533
+ Widget build(BuildContext context) {
2534
+ if (run == null) {
2535
+ return const _EmptyCard(
2536
+ title: 'Select a run',
2537
+ subtitle: 'Pick a run from the history list to inspect its steps.',
2538
+ );
2539
+ }
2540
+
2541
+ final selectedRun = run!;
2542
+ final snapshot = detail;
2543
+ return Column(
2544
+ children: <Widget>[
2545
+ _RunHeroCard(run: selectedRun, onDelete: onDelete),
2546
+ const SizedBox(height: 16),
2547
+ if (loading && snapshot == null)
2548
+ Card(
2549
+ child: Padding(
2550
+ padding: EdgeInsets.all(24),
2551
+ child: Row(
2552
+ children: <Widget>[
2553
+ SizedBox.square(
2554
+ dimension: 20,
2555
+ child: CircularProgressIndicator(strokeWidth: 2),
2556
+ ),
2557
+ SizedBox(width: 12),
2558
+ Text(
2559
+ 'Loading run detail...',
2560
+ style: TextStyle(color: _textSecondary),
2561
+ ),
2562
+ ],
2563
+ ),
2564
+ ),
2565
+ )
2566
+ else if (errorMessage case final message?) ...<Widget>[
2567
+ _InlineError(message: message),
2568
+ const SizedBox(height: 16),
2569
+ ] else if (snapshot != null) ...<Widget>[
2570
+ Wrap(
2571
+ spacing: 12,
2572
+ runSpacing: 12,
2573
+ children: <Widget>[
2574
+ _RunMetricCard(
2575
+ title: 'Steps',
2576
+ value: '${snapshot.steps.length}',
2577
+ helper: 'Recorded events',
2578
+ color: _info,
2579
+ ),
2580
+ _RunMetricCard(
2581
+ title: 'Completed tools',
2582
+ value: '${snapshot.completedTools}',
2583
+ helper: 'Successful tool calls',
2584
+ color: _success,
2585
+ ),
2586
+ _RunMetricCard(
2587
+ title: 'Failures',
2588
+ value: '${snapshot.failedTools}',
2589
+ helper: 'Tool errors',
2590
+ color: _danger,
2591
+ ),
2592
+ _RunMetricCard(
2593
+ title: 'Helpers',
2594
+ value: '${snapshot.helperCount}',
2595
+ helper: 'Subagents or helpers',
2596
+ color: _accentHover,
2597
+ ),
2598
+ ],
2599
+ ),
2600
+ const SizedBox(height: 16),
2601
+ _RunResponseCard(
2602
+ response: snapshot.response,
2603
+ onCopy: () => onCopyResponse(snapshot.response),
2604
+ ),
2605
+ const SizedBox(height: 16),
2606
+ _RunTimelineCard(steps: snapshot.steps, loading: loading),
2607
+ ] else
2608
+ const _EmptyCard(
2609
+ title: 'No detail available',
2610
+ subtitle: 'This run does not have step detail yet.',
2611
+ ),
2612
+ ],
2613
+ );
2614
+ }
2615
+ }
2616
+
2617
+ class _RunHeroCard extends StatelessWidget {
2618
+ const _RunHeroCard({required this.run, required this.onDelete});
2619
+
2620
+ final RunSummary run;
2621
+ final Future<void> Function() onDelete;
2622
+
2623
+ @override
2624
+ Widget build(BuildContext context) {
2625
+ return Container(
2626
+ width: double.infinity,
2627
+ padding: const EdgeInsets.all(22),
2628
+ decoration: BoxDecoration(
2629
+ gradient: LinearGradient(
2630
+ colors: <Color>[
2631
+ run.statusColor.withValues(alpha: 0.18),
2632
+ _bgSecondary,
2633
+ ],
2634
+ begin: Alignment.topLeft,
2635
+ end: Alignment.bottomRight,
2636
+ ),
2637
+ borderRadius: BorderRadius.circular(24),
2638
+ border: Border.all(color: _borderLight),
2639
+ ),
2640
+ child: Column(
2641
+ crossAxisAlignment: CrossAxisAlignment.start,
2642
+ children: <Widget>[
2643
+ Row(
2644
+ crossAxisAlignment: CrossAxisAlignment.start,
2645
+ children: <Widget>[
2646
+ Expanded(
2647
+ child: Column(
2648
+ crossAxisAlignment: CrossAxisAlignment.start,
2649
+ children: <Widget>[
2650
+ Wrap(
2651
+ spacing: 10,
2652
+ runSpacing: 10,
2653
+ children: <Widget>[
2654
+ _StatusPill(
2655
+ label: run.statusLabel,
2656
+ color: run.statusColor,
2657
+ ),
2658
+ _MetaPill(
2659
+ label: run.triggerLabel,
2660
+ icon: Icons.bolt_outlined,
2661
+ ),
2662
+ _MetaPill(
2663
+ label: run.modelLabel,
2664
+ icon: Icons.memory_outlined,
2665
+ ),
2666
+ ],
2667
+ ),
2668
+ const SizedBox(height: 16),
2669
+ Text(
2670
+ run.title,
2671
+ style: TextStyle(
2672
+ fontSize: 24,
2673
+ fontWeight: FontWeight.w800,
2674
+ height: 1.15,
2675
+ ),
2676
+ ),
2677
+ const SizedBox(height: 10),
2678
+ Wrap(
2679
+ spacing: 10,
2680
+ runSpacing: 10,
2681
+ children: <Widget>[
2682
+ _MetaPill(
2683
+ label: 'Started ${run.createdAtLabel}',
2684
+ icon: Icons.schedule_outlined,
2685
+ ),
2686
+ _MetaPill(
2687
+ label: run.durationLabel,
2688
+ icon: Icons.timer_outlined,
2689
+ ),
2690
+ _MetaPill(
2691
+ label: '${run.totalTokensLabel} tokens',
2692
+ icon: Icons.toll_outlined,
2693
+ ),
2694
+ _MetaPill(
2695
+ label: run.id.length <= 12
2696
+ ? run.id
2697
+ : '${run.id.substring(0, 12)}…',
2698
+ icon: Icons.tag_outlined,
2699
+ ),
2700
+ ],
2701
+ ),
2702
+ ],
2703
+ ),
2704
+ ),
2705
+ const SizedBox(width: 12),
2706
+ OutlinedButton.icon(
2707
+ onPressed: onDelete,
2708
+ icon: Icon(Icons.delete_outline),
2709
+ label: Text('Delete'),
2710
+ ),
2711
+ ],
2712
+ ),
2713
+ if (run.error.trim().isNotEmpty) ...<Widget>[
2714
+ const SizedBox(height: 16),
2715
+ Container(
2716
+ width: double.infinity,
2717
+ padding: const EdgeInsets.all(14),
2718
+ decoration: BoxDecoration(
2719
+ color: const Color(0x19EF4444),
2720
+ borderRadius: BorderRadius.circular(14),
2721
+ border: Border.all(color: const Color(0x4CEF4444)),
2722
+ ),
2723
+ child: Text(run.error, style: TextStyle(height: 1.45)),
2724
+ ),
2725
+ ],
2726
+ ],
2727
+ ),
2728
+ );
2729
+ }
2730
+ }
2731
+
2732
+ class _RunResponseCard extends StatelessWidget {
2733
+ const _RunResponseCard({required this.response, required this.onCopy});
2734
+
2735
+ final String response;
2736
+ final VoidCallback onCopy;
2737
+
2738
+ @override
2739
+ Widget build(BuildContext context) {
2740
+ return Card(
2741
+ child: Padding(
2742
+ padding: const EdgeInsets.all(18),
2743
+ child: Column(
2744
+ crossAxisAlignment: CrossAxisAlignment.start,
2745
+ children: <Widget>[
2746
+ Row(
2747
+ children: <Widget>[
2748
+ Expanded(child: _SectionTitle('Final Response')),
2749
+ OutlinedButton.icon(
2750
+ onPressed: response.trim().isEmpty ? null : onCopy,
2751
+ icon: Icon(Icons.copy_all_outlined),
2752
+ label: Text('Copy'),
2753
+ ),
2754
+ ],
2755
+ ),
2756
+ const SizedBox(height: 12),
2757
+ if (response.trim().isEmpty)
2758
+ Text(
2759
+ 'No final response was captured for this run.',
2760
+ style: TextStyle(color: _textSecondary),
2761
+ )
2762
+ else
2763
+ MarkdownBody(
2764
+ data: response,
2765
+ selectable: true,
2766
+ styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context))
2767
+ .copyWith(
2768
+ p: Theme.of(context).textTheme.bodyMedium?.copyWith(
2769
+ color: _textPrimary,
2770
+ height: 1.6,
2771
+ ),
2772
+ code: Theme.of(context).textTheme.bodyMedium?.copyWith(
2773
+ fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
2774
+ backgroundColor: _bgSecondary,
2775
+ color: _textPrimary,
2776
+ ),
2777
+ blockquoteDecoration: BoxDecoration(
2778
+ borderRadius: BorderRadius.circular(12),
2779
+ color: _bgSecondary,
2780
+ border: Border.all(color: _border),
2781
+ ),
2782
+ ),
2783
+ ),
2784
+ ],
2785
+ ),
2786
+ ),
2787
+ );
2788
+ }
2789
+ }
2790
+
2791
+ class _RunTimelineCard extends StatelessWidget {
2792
+ const _RunTimelineCard({required this.steps, required this.loading});
2793
+
2794
+ final List<RunStepItem> steps;
2795
+ final bool loading;
2796
+
2797
+ @override
2798
+ Widget build(BuildContext context) {
2799
+ return Card(
2800
+ child: Padding(
2801
+ padding: const EdgeInsets.all(18),
2802
+ child: Column(
2803
+ crossAxisAlignment: CrossAxisAlignment.start,
2804
+ children: <Widget>[
2805
+ Row(
2806
+ children: <Widget>[
2807
+ Expanded(child: _SectionTitle('Step Timeline')),
2808
+ if (loading)
2809
+ const SizedBox.square(
2810
+ dimension: 16,
2811
+ child: CircularProgressIndicator(strokeWidth: 2),
2812
+ ),
2813
+ ],
2814
+ ),
2815
+ const SizedBox(height: 12),
2816
+ if (steps.isEmpty)
2817
+ Text(
2818
+ 'No run steps recorded yet.',
2819
+ style: TextStyle(color: _textSecondary),
2820
+ )
2821
+ else
2822
+ ...steps.map((step) {
2823
+ return Padding(
2824
+ padding: const EdgeInsets.only(bottom: 10),
2825
+ child: _RunStepCard(step: step),
2826
+ );
2827
+ }),
2828
+ ],
2829
+ ),
2830
+ ),
2831
+ );
2832
+ }
2833
+ }
2834
+
2835
+ class _RunStepCard extends StatelessWidget {
2836
+ const _RunStepCard({required this.step});
2837
+
2838
+ final RunStepItem step;
2839
+
2840
+ @override
2841
+ Widget build(BuildContext context) {
2842
+ final theme = Theme.of(context);
2843
+ return Container(
2844
+ decoration: BoxDecoration(
2845
+ color: _bgSecondary,
2846
+ borderRadius: BorderRadius.circular(16),
2847
+ border: Border.all(color: _border),
2848
+ ),
2849
+ child: Theme(
2850
+ data: theme.copyWith(dividerColor: Colors.transparent),
2851
+ child: ExpansionTile(
2852
+ tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
2853
+ childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
2854
+ initiallyExpanded:
2855
+ step.status == 'failed' || step.status == 'running',
2856
+ leading: Container(
2857
+ width: 34,
2858
+ height: 34,
2859
+ decoration: BoxDecoration(
2860
+ color: step.statusColor.withValues(alpha: 0.16),
2861
+ shape: BoxShape.circle,
2862
+ ),
2863
+ child: Center(
2864
+ child: Text(
2865
+ '${step.displayIndex}',
2866
+ style: TextStyle(
2867
+ color: step.statusColor,
2868
+ fontWeight: FontWeight.w800,
2869
+ ),
2870
+ ),
2871
+ ),
2872
+ ),
2873
+ title: Column(
2874
+ crossAxisAlignment: CrossAxisAlignment.start,
2875
+ children: <Widget>[
2876
+ Text(step.label, style: TextStyle(fontWeight: FontWeight.w700)),
2877
+ const SizedBox(height: 6),
2878
+ Text(
2879
+ step.summary,
2880
+ maxLines: 2,
2881
+ overflow: TextOverflow.ellipsis,
2882
+ style: TextStyle(
2883
+ color: _textSecondary,
2884
+ fontSize: 12,
2885
+ height: 1.45,
2886
+ ),
2887
+ ),
2888
+ const SizedBox(height: 8),
2889
+ Wrap(
2890
+ spacing: 8,
2891
+ runSpacing: 8,
2892
+ children: <Widget>[
2893
+ _StatusPill(label: step.statusLabel, color: step.statusColor),
2894
+ _MetaPill(label: step.typeLabel, icon: Icons.layers_outlined),
2895
+ if (step.startedAt != null)
2896
+ _MetaPill(
2897
+ label: step.startedAtLabel!,
2898
+ icon: Icons.schedule_outlined,
2899
+ ),
2900
+ if (step.durationLabel != null)
2901
+ _MetaPill(
2902
+ label: step.durationLabel!,
2903
+ icon: Icons.timer_outlined,
2904
+ ),
2905
+ if (step.tokensUsed > 0)
2906
+ _MetaPill(
2907
+ label: '${_formatNumber(step.tokensUsed)} tokens',
2908
+ icon: Icons.toll_outlined,
2909
+ ),
2910
+ ],
2911
+ ),
2912
+ ],
2913
+ ),
2914
+ children: <Widget>[
2915
+ if (step.description.trim().isNotEmpty &&
2916
+ step.description.trim() != step.summary.trim())
2917
+ _RunDetailBlock(label: 'Description', value: step.description),
2918
+ if (step.inputSummary.trim().isNotEmpty)
2919
+ _RunDetailBlock(label: 'Input summary', value: step.inputSummary),
2920
+ if (step.toolInput.trim().isNotEmpty)
2921
+ _RunDetailBlock(
2922
+ label: 'Tool input',
2923
+ value: _truncateRunText(step.toolInput),
2924
+ monospace: true,
2925
+ ),
2926
+ if (step.error.trim().isNotEmpty)
2927
+ _RunDetailBlock(
2928
+ label: 'Error',
2929
+ value: step.error,
2930
+ monospace: true,
2931
+ )
2932
+ else if (step.result.trim().isNotEmpty)
2933
+ _RunDetailBlock(
2934
+ label: 'Result',
2935
+ value: _truncateRunText(step.result),
2936
+ monospace: true,
2937
+ ),
2938
+ ],
2939
+ ),
2940
+ ),
2941
+ );
2942
+ }
2943
+ }
2944
+
2945
+ class _RunDetailBlock extends StatelessWidget {
2946
+ const _RunDetailBlock({
2947
+ required this.label,
2948
+ required this.value,
2949
+ this.monospace = false,
2950
+ });
2951
+
2952
+ final String label;
2953
+ final String value;
2954
+ final bool monospace;
2955
+
2956
+ @override
2957
+ Widget build(BuildContext context) {
2958
+ return Padding(
2959
+ padding: const EdgeInsets.only(top: 12),
2960
+ child: Column(
2961
+ crossAxisAlignment: CrossAxisAlignment.start,
2962
+ children: <Widget>[
2963
+ Text(
2964
+ label,
2965
+ style: TextStyle(
2966
+ color: _textSecondary,
2967
+ fontWeight: FontWeight.w600,
2968
+ ),
2969
+ ),
2970
+ const SizedBox(height: 6),
2971
+ Container(
2972
+ width: double.infinity,
2973
+ padding: const EdgeInsets.all(12),
2974
+ decoration: BoxDecoration(
2975
+ color: _bgPrimary,
2976
+ borderRadius: BorderRadius.circular(12),
2977
+ border: Border.all(color: _border),
2978
+ ),
2979
+ child: SelectableText(
2980
+ value,
2981
+ style: TextStyle(
2982
+ height: 1.5,
2983
+ fontSize: 12.5,
2984
+ color: _textPrimary,
2985
+ fontFamily: monospace
2986
+ ? GoogleFonts.jetBrainsMono().fontFamily
2987
+ : null,
2988
+ ),
2989
+ ),
2990
+ ),
2991
+ ],
2992
+ ),
2993
+ );
2994
+ }
2995
+ }
2996
+
2997
+ Future<void> openMessagingConfig(BuildContext context, NeoAgentController controller,
2998
+ MessagingPlatformDescriptor platform,
2999
+ ) async {
3000
+ switch (platform.id) {
3001
+ case 'whatsapp':
3002
+ await _connectMessagingPlatformHelper(context, controller,
3003
+ platform: 'whatsapp',
3004
+ platformLabel: platform.label,
3005
+ );
3006
+ return;
3007
+ case 'telnyx':
3008
+ return _openTelnyxConfigHelper(context, controller);
3009
+ default:
3010
+ return _openGenericMessagingConfigHelper(context, controller, platform);
3011
+ }
3012
+ }
3013
+
3014
+ Future<bool> _connectMessagingPlatformHelper(BuildContext context, NeoAgentController controller, {
3015
+ required String platform,
3016
+ required String platformLabel,
3017
+ Map<String, dynamic>? config,
3018
+ Map<String, dynamic>? configSnapshot,
3019
+ }) async {
3020
+ try {
3021
+ await controller.connectMessagingPlatform(
3022
+ platform: platform,
3023
+ config: config,
3024
+ configSnapshot: configSnapshot,
3025
+ );
3026
+ return true;
3027
+ } catch (error) {
3028
+ if (!context.mounted) return false;
3029
+ final messenger = ScaffoldMessenger.maybeOf(context);
3030
+ messenger?.showSnackBar(
3031
+ SnackBar(
3032
+ content: Text(
3033
+ 'Failed to connect $platformLabel: ${controller.friendlyErrorMessage(error)}',
3034
+ ),
3035
+ ),
3036
+ );
3037
+ return false;
3038
+ }
3039
+ }
3040
+
3041
+ Future<void> _openTelnyxConfigHelper(BuildContext context, NeoAgentController controller) async {
3042
+ final saved = _jsonMap(
3043
+ _decodeMaybeJson(controller.settings['telnyx_config']),
3044
+ );
3045
+ final apiKey = TextEditingController(
3046
+ text: saved['apiKey']?.toString() ?? '',
3047
+ );
3048
+ final phoneNumber = TextEditingController(
3049
+ text: saved['phoneNumber']?.toString() ?? '',
3050
+ );
3051
+ final connectionId = TextEditingController(
3052
+ text: saved['connectionId']?.toString() ?? '',
3053
+ );
3054
+ final webhookUrl = TextEditingController(
3055
+ text: saved['webhookUrl']?.toString() ?? controller.backendUrl,
3056
+ );
3057
+
3058
+ try {
3059
+ await showDialog<void>(
3060
+ context: context,
3061
+ builder: (context) {
3062
+ return StatefulBuilder(
3063
+ builder: (context, setLocalState) {
3064
+ return AlertDialog(
3065
+ backgroundColor: _bgCard,
3066
+ title: Text('Telnyx Voice'),
3067
+ content: SizedBox(
3068
+ width: 620,
3069
+ child: SingleChildScrollView(
3070
+ child: Column(
3071
+ mainAxisSize: MainAxisSize.min,
3072
+ children: <Widget>[
3073
+ TextField(
3074
+ controller: apiKey,
3075
+ obscureText: true,
3076
+ decoration: const InputDecoration(
3077
+ labelText: 'API Key',
3078
+ ),
3079
+ ),
3080
+ const SizedBox(height: 12),
3081
+ TextField(
3082
+ controller: phoneNumber,
3083
+ decoration: const InputDecoration(
3084
+ labelText: 'Phone Number',
3085
+ ),
3086
+ ),
3087
+ const SizedBox(height: 12),
3088
+ TextField(
3089
+ controller: connectionId,
3090
+ decoration: const InputDecoration(
3091
+ labelText: 'Connection ID',
3092
+ ),
3093
+ ),
3094
+ const SizedBox(height: 12),
3095
+ TextField(
3096
+ controller: webhookUrl,
3097
+ decoration: const InputDecoration(
3098
+ labelText: 'Webhook Base URL',
3099
+ ),
3100
+ ),
3101
+ const SizedBox(height: 12),
3102
+ Text(
3103
+ 'Voice STT/TTS providers and models are configured in global Settings > Voice.',
3104
+ style: TextStyle(color: _textSecondary),
3105
+ ),
3106
+ ],
3107
+ ),
3108
+ ),
3109
+ ),
3110
+ actions: <Widget>[
3111
+ TextButton(
3112
+ onPressed: () => Navigator.of(context).pop(),
3113
+ child: Text('Cancel'),
3114
+ ),
3115
+ FilledButton(
3116
+ onPressed: () async {
3117
+ final config = <String, dynamic>{
3118
+ 'apiKey': apiKey.text.trim(),
3119
+ 'phoneNumber': phoneNumber.text.trim(),
3120
+ 'connectionId': connectionId.text.trim(),
3121
+ 'webhookUrl': webhookUrl.text.trim(),
3122
+ };
3123
+ final connected = await _connectMessagingPlatformHelper(context, controller,
3124
+ platform: 'telnyx',
3125
+ platformLabel: 'Telnyx Voice',
3126
+ config: config,
3127
+ configSnapshot: <String, dynamic>{
3128
+ 'telnyx_config': jsonEncode(config),
3129
+ },
3130
+ );
3131
+ if (connected && context.mounted) {
3132
+ Navigator.of(context).pop();
3133
+ }
3134
+ },
3135
+ child: Text('Connect'),
3136
+ ),
3137
+ ],
3138
+ );
3139
+ },
3140
+ );
3141
+ },
3142
+ );
3143
+ } finally {
3144
+ apiKey.dispose();
3145
+ phoneNumber.dispose();
3146
+ connectionId.dispose();
3147
+ webhookUrl.dispose();
3148
+ }
3149
+ }
3150
+
3151
+ Future<void> _openGenericMessagingConfigHelper(BuildContext context, NeoAgentController controller,
3152
+ MessagingPlatformDescriptor platform,
3153
+ ) async {
3154
+ final saved = _jsonMap(
3155
+ _decodeMaybeJson(controller.settings[platform.settingsKey]),
3156
+ );
3157
+ final textControllers = <String, TextEditingController>{};
3158
+ final boolValues = <String, bool>{};
3159
+ for (final field in platform.configFields) {
3160
+ final savedValue = field.settingsKey == null
3161
+ ? saved[field.key]
3162
+ : controller.settings[field.storageKey];
3163
+ if (field.kind == MessagingConfigFieldKind.boolean) {
3164
+ boolValues[field.key] =
3165
+ savedValue == true || savedValue?.toString() == 'true';
3166
+ } else {
3167
+ textControllers[field.key] = TextEditingController(
3168
+ text: savedValue?.toString() ?? field.defaultValue ?? '',
3169
+ );
3170
+ }
3171
+ }
3172
+
3173
+ try {
3174
+ await showDialog<void>(
3175
+ context: context,
3176
+ builder: (context) {
3177
+ return StatefulBuilder(
3178
+ builder: (context, setLocalState) {
3179
+ return AlertDialog(
3180
+ backgroundColor: _bgCard,
3181
+ title: Text(platform.label),
3182
+ content: SizedBox(
3183
+ width: 620,
3184
+ child: SingleChildScrollView(
3185
+ child: Column(
3186
+ mainAxisSize: MainAxisSize.min,
3187
+ children: <Widget>[
3188
+ if (platform.configFields.isEmpty)
3189
+ Text(
3190
+ 'No extra settings are required.',
3191
+ style: TextStyle(color: _textSecondary),
3192
+ )
3193
+ else
3194
+ ...platform.configFields.map((field) {
3195
+ if (field.kind ==
3196
+ MessagingConfigFieldKind.boolean) {
3197
+ return SwitchListTile(
3198
+ contentPadding: EdgeInsets.zero,
3199
+ title: Text(field.label),
3200
+ value: boolValues[field.key] ?? false,
3201
+ onChanged: (value) {
3202
+ setLocalState(() {
3203
+ boolValues[field.key] = value;
3204
+ });
3205
+ },
3206
+ );
3207
+ }
3208
+ final controller = textControllers[field.key]!;
3209
+ return Padding(
3210
+ padding: const EdgeInsets.only(bottom: 12),
3211
+ child: TextField(
3212
+ controller: controller,
3213
+ obscureText:
3214
+ field.obscure ||
3215
+ field.kind ==
3216
+ MessagingConfigFieldKind.password,
3217
+ minLines:
3218
+ field.kind ==
3219
+ MessagingConfigFieldKind.multiline
3220
+ ? 4
3221
+ : 1,
3222
+ maxLines:
3223
+ field.kind ==
3224
+ MessagingConfigFieldKind.multiline
3225
+ ? 8
3226
+ : 1,
3227
+ decoration: InputDecoration(
3228
+ labelText: field.label,
3229
+ ),
3230
+ ),
3231
+ );
3232
+ }),
3233
+ const SizedBox(height: 8),
3234
+ if (platform.id == 'meshtastic')
3235
+ Text(
3236
+ 'Meshtastic connects directly to the device TCP API on port 4403 by default. Normal chat is limited to the configured channel.',
3237
+ style: TextStyle(
3238
+ color: _textSecondary,
3239
+ fontSize: 12,
3240
+ ),
3241
+ )
3242
+ else
3243
+ SelectableText(
3244
+ 'Inbound webhook: ${controller.backendUrl}/api/messaging/webhook/${platform.id}',
3245
+ style: TextStyle(
3246
+ color: _textSecondary,
3247
+ fontSize: 12,
3248
+ ),
3249
+ ),
3250
+ ],
3251
+ ),
3252
+ ),
3253
+ ),
3254
+ actions: <Widget>[
3255
+ TextButton(
3256
+ onPressed: () => Navigator.of(context).pop(),
3257
+ child: Text('Cancel'),
3258
+ ),
3259
+ FilledButton(
3260
+ onPressed: () async {
3261
+ final config = <String, dynamic>{};
3262
+ final snapshot = <String, dynamic>{};
3263
+ for (final field in platform.configFields) {
3264
+ if (field.kind == MessagingConfigFieldKind.boolean ||
3265
+ !field.includeInConfig) {
3266
+ continue;
3267
+ }
3268
+ final controller = textControllers[field.key];
3269
+ final value = controller?.text.trim() ?? '';
3270
+ if (value.isNotEmpty) config[field.key] = value;
3271
+ }
3272
+ for (final field in platform.configFields) {
3273
+ if (field.kind == MessagingConfigFieldKind.boolean) {
3274
+ final value = boolValues[field.key] ?? false;
3275
+ if (field.includeInConfig) {
3276
+ config[field.key] = value;
3277
+ }
3278
+ if (field.settingsKey != null) {
3279
+ snapshot[field.storageKey] = value;
3280
+ }
3281
+ } else if (field.settingsKey != null) {
3282
+ final controller = textControllers[field.key];
3283
+ final value = controller?.text.trim() ?? '';
3284
+ if (value.isNotEmpty) {
3285
+ snapshot[field.storageKey] = value;
3286
+ }
3287
+ }
3288
+ }
3289
+ snapshot[platform.settingsKey] = jsonEncode(config);
3290
+ final connected = await _connectMessagingPlatformHelper(context, controller,
3291
+ platform: platform.id,
3292
+ platformLabel: platform.label,
3293
+ config: config,
3294
+ configSnapshot: snapshot,
3295
+ );
3296
+ if (connected && context.mounted) {
3297
+ Navigator.of(context).pop();
3298
+ }
3299
+ },
3300
+ child: Text(
3301
+ 'Connect',
3302
+ ),
3303
+ ),
3304
+ ],
3305
+ );
3306
+ },
3307
+ );
3308
+ },
3309
+ );
3310
+ } finally {
3311
+ for (final controller in textControllers.values) {
3312
+ controller.dispose();
3313
+ }
3314
+ }
3315
+ }
3316
+