neoagent 2.3.1-beta.4 → 2.3.1-beta.41

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