neoagent 2.3.1-beta.3 → 2.3.1-beta.30

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