neoagent 2.3.1-beta.11 → 2.3.1-beta.111

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 (390) hide show
  1. package/.env.example +53 -5
  2. package/README.md +16 -7
  3. package/com.neoagent.plist +1 -1
  4. package/docs/automation.md +82 -41
  5. package/docs/capabilities.md +85 -89
  6. package/docs/configuration.md +87 -94
  7. package/docs/getting-started.md +46 -27
  8. package/docs/hardware.md +1 -1
  9. package/docs/index.md +24 -30
  10. package/docs/integrations.md +46 -64
  11. package/docs/migration.md +41 -146
  12. package/docs/operations.md +23 -20
  13. package/docs/skills.md +20 -20
  14. package/docs/why-neoagent.md +15 -13
  15. package/extensions/chrome-browser/background.mjs +10 -2
  16. package/flutter_app/.metadata +42 -0
  17. package/flutter_app/DESIGN_SYSTEM.md +166 -0
  18. package/flutter_app/README.md +21 -0
  19. package/flutter_app/analysis_options.yaml +32 -0
  20. package/flutter_app/android/app/build.gradle.kts +111 -0
  21. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  22. package/flutter_app/android/app/src/main/AndroidManifest.xml +185 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +888 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/auto/NeoAgentCarAppService.kt +114 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  30. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  31. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  32. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  33. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  34. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  35. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnection.kt +118 -0
  36. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnectionService.kt +86 -0
  37. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  38. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  39. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  40. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  41. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  42. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  43. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  44. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  45. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  46. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  47. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  48. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  49. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  50. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  51. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  52. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  53. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  54. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  55. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  56. package/flutter_app/android/app/src/main/res/values/arrays.xml +8 -0
  57. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  58. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  59. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  60. package/flutter_app/android/app/src/main/res/xml/automotive_app_desc.xml +4 -0
  61. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  62. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  63. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  64. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  65. package/flutter_app/android/build.gradle.kts +24 -0
  66. package/flutter_app/android/ci-release.keystore +0 -0
  67. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  68. package/flutter_app/android/gradle.properties +3 -0
  69. package/flutter_app/android/key.properties +4 -0
  70. package/flutter_app/android/settings.gradle.kts +26 -0
  71. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  72. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  73. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  74. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  75. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  76. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  77. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  78. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  79. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  80. package/flutter_app/lib/features/location/location_service.dart +117 -0
  81. package/flutter_app/lib/features/notifications/notification_interceptor.dart +73 -0
  82. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +571 -0
  83. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +275 -0
  84. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +366 -0
  85. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +57 -0
  86. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +218 -0
  87. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +46 -0
  88. package/flutter_app/lib/main.dart +107 -0
  89. package/flutter_app/lib/main_account_settings.dart +1340 -0
  90. package/flutter_app/lib/main_admin.dart +900 -0
  91. package/flutter_app/lib/main_app_shell.dart +2162 -0
  92. package/flutter_app/lib/main_chat.dart +3902 -0
  93. package/flutter_app/lib/main_controller.dart +7229 -0
  94. package/flutter_app/lib/main_devices.dart +1738 -0
  95. package/flutter_app/lib/main_integrations.dart +917 -0
  96. package/flutter_app/lib/main_launcher.dart +959 -0
  97. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  98. package/flutter_app/lib/main_models.dart +3807 -0
  99. package/flutter_app/lib/main_navigation.dart +206 -0
  100. package/flutter_app/lib/main_operations.dart +5249 -0
  101. package/flutter_app/lib/main_recordings.dart +920 -0
  102. package/flutter_app/lib/main_runtime.dart +869 -0
  103. package/flutter_app/lib/main_settings.dart +2288 -0
  104. package/flutter_app/lib/main_shared.dart +3393 -0
  105. package/flutter_app/lib/main_spacing.dart +33 -0
  106. package/flutter_app/lib/main_theme.dart +276 -0
  107. package/flutter_app/lib/main_unified.dart +388 -0
  108. package/flutter_app/lib/main_voice_assistant.dart +957 -0
  109. package/flutter_app/lib/src/analytics_service.dart +294 -0
  110. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  111. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  112. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  113. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  114. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  115. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  116. package/flutter_app/lib/src/android_auto_bridge.dart +59 -0
  117. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  118. package/flutter_app/lib/src/app_launch_bridge.dart +58 -0
  119. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  120. package/flutter_app/lib/src/backend_client.dart +1870 -0
  121. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  122. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  123. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  124. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  125. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  126. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  127. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  128. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  129. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  130. package/flutter_app/lib/src/health_bridge.dart +136 -0
  131. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  132. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  133. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  134. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  135. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  136. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  137. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  138. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  139. package/flutter_app/lib/src/oauth_launcher_io.dart +95 -0
  140. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  141. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  142. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  143. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  144. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  145. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  146. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  147. package/flutter_app/lib/src/theme/palette.dart +81 -0
  148. package/flutter_app/lib/src/web_app_update_monitor.dart +17 -0
  149. package/flutter_app/lib/src/web_app_update_monitor_stub.dart +24 -0
  150. package/flutter_app/lib/src/web_app_update_monitor_web.dart +123 -0
  151. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  152. package/flutter_app/linux/CMakeLists.txt +128 -0
  153. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  154. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +47 -0
  155. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  156. package/flutter_app/linux/flutter/generated_plugins.cmake +32 -0
  157. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  158. package/flutter_app/linux/runner/main.cc +6 -0
  159. package/flutter_app/linux/runner/my_application.cc +144 -0
  160. package/flutter_app/linux/runner/my_application.h +18 -0
  161. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  162. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  163. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  164. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +48 -0
  165. package/flutter_app/macos/Podfile +42 -0
  166. package/flutter_app/macos/Podfile.lock +87 -0
  167. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  168. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  169. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  170. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  171. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  172. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  173. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  174. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  175. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  176. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  177. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  178. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  179. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  180. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  181. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  182. package/flutter_app/macos/Runner/Info.plist +36 -0
  183. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  184. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  185. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  186. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  187. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  188. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  189. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  190. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  191. package/flutter_app/patch_strings.py +12 -0
  192. package/flutter_app/pubspec.lock +1256 -0
  193. package/flutter_app/pubspec.yaml +59 -0
  194. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  195. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  196. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  197. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  198. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  199. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  200. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  201. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  202. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  203. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  204. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  205. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  206. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  207. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  208. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  209. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  210. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  211. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  212. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  213. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  214. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  215. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  216. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  217. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  218. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  219. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  220. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  221. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  222. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  223. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  224. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  225. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  226. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  227. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  228. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  229. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  230. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  231. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  232. package/flutter_app/web/favicon.png +0 -0
  233. package/flutter_app/web/favicon.svg +12 -0
  234. package/flutter_app/web/icons/Icon-192.png +0 -0
  235. package/flutter_app/web/icons/Icon-512.png +0 -0
  236. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  237. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  238. package/flutter_app/web/index.html +39 -0
  239. package/flutter_app/web/manifest.json +35 -0
  240. package/flutter_app/windows/CMakeLists.txt +108 -0
  241. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  242. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +50 -0
  243. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  244. package/flutter_app/windows/flutter/generated_plugins.cmake +36 -0
  245. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  246. package/flutter_app/windows/runner/Runner.rc +121 -0
  247. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  248. package/flutter_app/windows/runner/flutter_window.h +37 -0
  249. package/flutter_app/windows/runner/main.cpp +53 -0
  250. package/flutter_app/windows/runner/resource.h +16 -0
  251. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  252. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  253. package/flutter_app/windows/runner/utils.cpp +65 -0
  254. package/flutter_app/windows/runner/utils.h +19 -0
  255. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  256. package/flutter_app/windows/runner/win32_window.h +102 -0
  257. package/lib/install_helpers.js +31 -0
  258. package/lib/manager.js +579 -122
  259. package/package.json +10 -3
  260. package/runtime/paths.js +137 -0
  261. package/server/config/analytics.js +30 -0
  262. package/server/db/database.js +296 -22
  263. package/server/guest-agent.android.package.json +13 -0
  264. package/server/guest-agent.browser.package.json +14 -0
  265. package/server/guest_agent.js +98 -52
  266. package/server/http/middleware.js +5 -2
  267. package/server/http/routes.js +3 -0
  268. package/server/http/static.js +41 -1
  269. package/server/index.js +15 -16
  270. package/server/public/.last_build_id +1 -1
  271. package/server/public/assets/AssetManifest.bin +1 -1
  272. package/server/public/assets/AssetManifest.bin.json +1 -1
  273. package/server/public/assets/AssetManifest.json +1 -1
  274. package/server/public/assets/NOTICES +644 -155
  275. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  276. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  277. package/server/public/assets/packages/mixpanel_flutter/assets/mixpanel.js +3 -0
  278. package/server/public/canvaskit/wimp.wasm +0 -0
  279. package/server/public/flutter_bootstrap.js +2 -2
  280. package/server/public/main.dart.js +96067 -88139
  281. package/server/routes/account.js +26 -1
  282. package/server/routes/agents.js +6 -2
  283. package/server/routes/android.js +22 -6
  284. package/server/routes/auth.js +25 -5
  285. package/server/routes/browser.js +47 -13
  286. package/server/routes/integrations.js +22 -1
  287. package/server/routes/memory.js +168 -6
  288. package/server/routes/runtime.js +14 -0
  289. package/server/routes/settings.js +102 -24
  290. package/server/routes/skills.js +12 -0
  291. package/server/routes/social_video.js +66 -0
  292. package/server/routes/tasks.js +22 -2
  293. package/server/routes/triggers.js +44 -35
  294. package/server/routes/wearable.js +67 -0
  295. package/server/routes/widgets.js +4 -4
  296. package/server/services/ai/capabilityHealth.js +23 -51
  297. package/server/services/ai/compaction.js +1 -1
  298. package/server/services/ai/deliverables/artifact_helpers.js +190 -0
  299. package/server/services/ai/deliverables/contracts.js +113 -0
  300. package/server/services/ai/deliverables/index.js +20 -0
  301. package/server/services/ai/deliverables/selector.js +94 -0
  302. package/server/services/ai/deliverables/validator.js +63 -0
  303. package/server/services/ai/deliverables/workflows.js +207 -0
  304. package/server/services/ai/engine.js +363 -27
  305. package/server/services/ai/hooks.js +127 -0
  306. package/server/services/ai/integrated_tools/index.js +98 -0
  307. package/server/services/ai/integrated_tools/remotion.js +373 -0
  308. package/server/services/ai/integrated_tools/shared.js +204 -0
  309. package/server/services/ai/integrated_tools/slidev.js +207 -0
  310. package/server/services/ai/loopPolicy.js +146 -0
  311. package/server/services/ai/models.js +31 -4
  312. package/server/services/ai/providers/claudeCode.js +49 -0
  313. package/server/services/ai/providers/openai.js +2 -1
  314. package/server/services/ai/providers/openaiCodex.js +422 -15
  315. package/server/services/ai/recordingInsights.js +11 -13
  316. package/server/services/ai/runEvents.js +100 -0
  317. package/server/services/ai/settings.js +12 -2
  318. package/server/services/ai/systemPrompt.js +15 -1
  319. package/server/services/ai/taskAnalysis.js +2 -0
  320. package/server/services/ai/toolResult.js +20 -0
  321. package/server/services/ai/toolRunner.js +46 -6
  322. package/server/services/ai/toolSelector.js +54 -1
  323. package/server/services/ai/tools.js +210 -116
  324. package/server/services/android/android_bootstrap_worker.js +63 -0
  325. package/server/services/android/controller.js +442 -1760
  326. package/server/services/bootstrap_helpers.js +0 -34
  327. package/server/services/browser/controller.js +279 -49
  328. package/server/services/cli/executor.js +40 -1
  329. package/server/services/desktop/screenRecorder.js +65 -9
  330. package/server/services/integrations/figma/provider.js +1 -0
  331. package/server/services/integrations/github/provider.js +3 -3
  332. package/server/services/integrations/google/provider.js +1 -0
  333. package/server/services/integrations/manager.js +25 -5
  334. package/server/services/integrations/microsoft/provider.js +1 -0
  335. package/server/services/integrations/oauth_provider.js +15 -5
  336. package/server/services/integrations/provider_config_store.js +22 -13
  337. package/server/services/integrations/registry.js +2 -4
  338. package/server/services/integrations/spotify/provider.js +1 -0
  339. package/server/services/integrations/trello/provider.js +842 -0
  340. package/server/services/manager.js +88 -155
  341. package/server/services/mcp/client.js +120 -23
  342. package/server/services/memory/llm_transfer.js +218 -0
  343. package/server/services/memory/manager.js +260 -33
  344. package/server/services/messaging/access_policy.js +10 -0
  345. package/server/services/messaging/automation.js +1 -1
  346. package/server/services/messaging/manager.js +64 -1
  347. package/server/services/messaging/meshtastic.js +277 -0
  348. package/server/services/messaging/meshtastic_env.js +40 -0
  349. package/server/services/messaging/meshtastic_protocol.js +530 -0
  350. package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
  351. package/server/services/runtime/backends/local-vm.js +221 -158
  352. package/server/services/runtime/docker-vm-manager.js +392 -0
  353. package/server/services/runtime/guest_bootstrap.js +654 -0
  354. package/server/services/runtime/manager.js +85 -29
  355. package/server/services/runtime/settings.js +22 -31
  356. package/server/services/runtime/validation.js +16 -31
  357. package/server/services/social_video/adapters/base.js +26 -0
  358. package/server/services/social_video/adapters/index.js +27 -0
  359. package/server/services/social_video/adapters/instagram.js +17 -0
  360. package/server/services/social_video/adapters/tiktok.js +17 -0
  361. package/server/services/social_video/adapters/x.js +17 -0
  362. package/server/services/social_video/adapters/youtube.js +17 -0
  363. package/server/services/social_video/captions.js +187 -0
  364. package/server/services/social_video/frame.js +42 -0
  365. package/server/services/social_video/index.js +7 -0
  366. package/server/services/social_video/metadata.js +63 -0
  367. package/server/services/social_video/result.js +63 -0
  368. package/server/services/social_video/service.js +720 -0
  369. package/server/services/social_video/url.js +83 -0
  370. package/server/services/tasks/integration_runtime.js +1 -1
  371. package/server/services/tasks/runtime.js +1 -1
  372. package/server/services/tasks/task_repository.js +3 -6
  373. package/server/services/voice/openaiSpeech.js +6 -1
  374. package/server/services/voice/providers.js +142 -14
  375. package/server/services/voice/runtime.js +8 -8
  376. package/server/services/voice/runtimeManager.js +156 -19
  377. package/server/services/voice/turnRunner.js +29 -9
  378. package/server/services/wearable/firmware_manifest.js +353 -0
  379. package/server/services/wearable/gateway.js +350 -0
  380. package/server/services/wearable/protocol.js +45 -0
  381. package/server/services/wearable/service.js +240 -0
  382. package/server/services/websocket.js +3 -1
  383. package/server/services/widgets/focus_widget.js +137 -0
  384. package/server/services/widgets/service.js +133 -2
  385. package/server/services/workspace/manager.js +301 -0
  386. package/server/utils/deployment.js +16 -9
  387. package/server/services/integrations/home_assistant/provider.js +0 -630
  388. package/server/services/messaging/access_policy.test.js +0 -228
  389. package/server/services/runtime/backends/host.js +0 -37
  390. package/server/services/runtime/qemu.js +0 -391
@@ -0,0 +1,3393 @@
1
+ part of 'main.dart';
2
+
3
+ EdgeInsets _pagePadding(BuildContext context) {
4
+ final width = MediaQuery.sizeOf(context).width;
5
+ if (width >= 1280) {
6
+ return const EdgeInsets.fromLTRB(40, 34, 40, 40);
7
+ }
8
+ if (width >= 900) {
9
+ return const EdgeInsets.fromLTRB(30, 28, 30, 32);
10
+ }
11
+ return const EdgeInsets.fromLTRB(20, 20, 20, 28);
12
+ }
13
+
14
+ class _AmbientBackdrop extends StatefulWidget {
15
+ const _AmbientBackdrop({required this.child});
16
+
17
+ final Widget child;
18
+
19
+ @override
20
+ State<_AmbientBackdrop> createState() => _AmbientBackdropState();
21
+ }
22
+
23
+ class _AmbientBackdropState extends State<_AmbientBackdrop>
24
+ with SingleTickerProviderStateMixin {
25
+ late final AnimationController _controller;
26
+
27
+ @override
28
+ void initState() {
29
+ super.initState();
30
+ _controller = AnimationController(
31
+ vsync: this,
32
+ duration: const Duration(seconds: 24),
33
+ )..repeat(reverse: true);
34
+ }
35
+
36
+ @override
37
+ void dispose() {
38
+ _controller.dispose();
39
+ super.dispose();
40
+ }
41
+
42
+ @override
43
+ Widget build(BuildContext context) {
44
+ return DecoratedBox(
45
+ decoration: BoxDecoration(gradient: _appBackgroundGradient),
46
+ child: AnimatedBuilder(
47
+ animation: _controller,
48
+ builder: (context, _) {
49
+ final t = Curves.easeInOut.transform(_controller.value);
50
+ return Stack(
51
+ children: <Widget>[
52
+ Positioned(
53
+ top: -120 + (t * 22),
54
+ left: -90 + (t * 18),
55
+ child: _BlurOrb(
56
+ size: 340,
57
+ color: _accent.withValues(alpha: 0.9),
58
+ ),
59
+ ),
60
+ Positioned(
61
+ top: 90 - (t * 26),
62
+ right: -120 + (t * 22),
63
+ child: _BlurOrb(
64
+ size: 280,
65
+ color: _accentAlt.withValues(alpha: 0.85),
66
+ ),
67
+ ),
68
+ Positioned(
69
+ bottom: -140 + (t * 16),
70
+ left: 100 - (t * 24),
71
+ child: _BlurOrb(
72
+ size: 360,
73
+ color: _accent.withValues(alpha: 0.45),
74
+ ),
75
+ ),
76
+ Positioned.fill(
77
+ child: IgnorePointer(
78
+ child: DecoratedBox(
79
+ decoration: BoxDecoration(
80
+ gradient: LinearGradient(
81
+ colors: <Color>[
82
+ Colors.white.withValues(alpha: 0.05),
83
+ Colors.transparent,
84
+ Colors.black.withValues(alpha: 0.12),
85
+ ],
86
+ stops: const <double>[0, 0.32, 1],
87
+ begin: Alignment.topCenter,
88
+ end: Alignment.bottomCenter,
89
+ ),
90
+ ),
91
+ ),
92
+ ),
93
+ ),
94
+ Positioned.fill(
95
+ child: IgnorePointer(
96
+ child: DecoratedBox(
97
+ decoration: BoxDecoration(
98
+ gradient: RadialGradient(
99
+ center: Alignment(0.75 - (t * 0.15), -0.9 + (t * 0.1)),
100
+ radius: 0.95,
101
+ colors: <Color>[
102
+ _glassHighlight.withValues(alpha: 0.14),
103
+ Colors.transparent,
104
+ ],
105
+ ),
106
+ ),
107
+ ),
108
+ ),
109
+ ),
110
+ widget.child,
111
+ ],
112
+ );
113
+ },
114
+ ),
115
+ );
116
+ }
117
+ }
118
+
119
+ class _EntranceMotion extends StatefulWidget {
120
+ const _EntranceMotion({required this.child});
121
+
122
+ final Widget child;
123
+
124
+ @override
125
+ State<_EntranceMotion> createState() => _EntranceMotionState();
126
+ }
127
+
128
+ class _EntranceMotionState extends State<_EntranceMotion> {
129
+ bool _visible = false;
130
+
131
+ @override
132
+ void initState() {
133
+ super.initState();
134
+ WidgetsBinding.instance.addPostFrameCallback((_) {
135
+ if (mounted) {
136
+ setState(() {
137
+ _visible = true;
138
+ });
139
+ }
140
+ });
141
+ }
142
+
143
+ @override
144
+ Widget build(BuildContext context) {
145
+ return AnimatedSlide(
146
+ duration: const Duration(milliseconds: 700),
147
+ curve: Curves.easeOutCubic,
148
+ offset: _visible ? Offset.zero : const Offset(0, 0.035),
149
+ child: AnimatedOpacity(
150
+ duration: const Duration(milliseconds: 700),
151
+ curve: Curves.easeOutCubic,
152
+ opacity: _visible ? 1 : 0,
153
+ child: widget.child,
154
+ ),
155
+ );
156
+ }
157
+ }
158
+
159
+ class _GlassSurface extends StatelessWidget {
160
+ const _GlassSurface({
161
+ super.key,
162
+ required this.child,
163
+ this.width,
164
+ this.padding,
165
+ this.borderRadius = const BorderRadius.all(Radius.circular(24)),
166
+ this.blurSigma = 22,
167
+ this.fillColor,
168
+ this.borderColor,
169
+ this.overlayGradient,
170
+ this.boxShadow,
171
+ });
172
+
173
+ final Widget child;
174
+ final double? width;
175
+ final EdgeInsetsGeometry? padding;
176
+ final BorderRadius borderRadius;
177
+ final double blurSigma;
178
+ final Color? fillColor;
179
+ final Color? borderColor;
180
+ final Gradient? overlayGradient;
181
+ final List<BoxShadow>? boxShadow;
182
+
183
+ @override
184
+ Widget build(BuildContext context) {
185
+ return SizedBox(
186
+ width: width,
187
+ child: DecoratedBox(
188
+ decoration: BoxDecoration(
189
+ borderRadius: borderRadius,
190
+ boxShadow: boxShadow,
191
+ ),
192
+ child: ClipRRect(
193
+ borderRadius: borderRadius,
194
+ child: BackdropFilter(
195
+ filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
196
+ child: DecoratedBox(
197
+ decoration: BoxDecoration(
198
+ color: fillColor ?? _glassFill,
199
+ gradient: overlayGradient ?? _liquidMetalGradient,
200
+ borderRadius: borderRadius,
201
+ border: Border.all(color: borderColor ?? _glassBorder),
202
+ ),
203
+ child: DecoratedBox(
204
+ decoration: BoxDecoration(
205
+ borderRadius: borderRadius,
206
+ gradient: LinearGradient(
207
+ colors: <Color>[
208
+ _glassHighlight.withValues(alpha: 0.2),
209
+ Colors.transparent,
210
+ Colors.black.withValues(alpha: 0.06),
211
+ ],
212
+ stops: const <double>[0, 0.22, 1],
213
+ begin: Alignment.topLeft,
214
+ end: Alignment.bottomRight,
215
+ ),
216
+ ),
217
+ child: Padding(
218
+ padding: padding ?? EdgeInsets.zero,
219
+ child: child,
220
+ ),
221
+ ),
222
+ ),
223
+ ),
224
+ ),
225
+ ),
226
+ );
227
+ }
228
+ }
229
+
230
+ List<AppSection> _mainSections(NeoAgentController controller) {
231
+ return <AppSection>[
232
+ AppSection.chat,
233
+ AppSection.recordings,
234
+ AppSection.runs,
235
+ AppSection.devices,
236
+ AppSection.tasks,
237
+ AppSection.widgets,
238
+ AppSection.integrations,
239
+ AppSection.memory,
240
+ if (controller.showHealthSection) AppSection.health,
241
+ AppSection.settings,
242
+ AppSection.agents,
243
+ AppSection.messaging,
244
+ ];
245
+ }
246
+
247
+ List<Widget> _buildSidebarItems(
248
+ NeoAgentController controller, {
249
+ required ValueChanged<AppSection> onSelect,
250
+ required SidebarGroup? expandedGroup,
251
+ required ValueChanged<SidebarGroup> onToggleGroup,
252
+ }) {
253
+ final widgets = <Widget>[];
254
+ final mainSections = _mainSections(controller);
255
+ final selectedSidebarSection = mainSections.contains(
256
+ controller.selectedSection.sidebarSection,
257
+ );
258
+ for (final group in SidebarGroup.values) {
259
+ final sections = mainSections
260
+ .where((section) => section.group == group)
261
+ .toList();
262
+ if (sections.isEmpty) {
263
+ continue;
264
+ }
265
+
266
+ final active =
267
+ selectedSidebarSection &&
268
+ controller.selectedSection.sidebarSection.group == group;
269
+ final defaultSection = sections.first;
270
+ final hasChildren = sections.length > 1;
271
+ final expanded = expandedGroup == group;
272
+
273
+ widgets.add(
274
+ _SidebarButton(
275
+ label: group.label,
276
+ icon: group.icon,
277
+ active: active,
278
+ trailing: hasChildren
279
+ ? Icon(
280
+ expanded ? Icons.expand_less : Icons.expand_more,
281
+ size: 16,
282
+ color: active ? _accent : _textMuted,
283
+ )
284
+ : null,
285
+ onTap: hasChildren
286
+ ? () => onToggleGroup(group)
287
+ : () => onSelect(defaultSection),
288
+ ),
289
+ );
290
+
291
+ if (!hasChildren || !expanded) {
292
+ continue;
293
+ }
294
+
295
+ for (final section in sections) {
296
+ widgets.add(
297
+ _SidebarButton(
298
+ label: section.label,
299
+ icon: section.icon,
300
+ active: controller.selectedSection.sidebarSection == section,
301
+ indent: 18,
302
+ iconSize: 16,
303
+ fontSize: 12,
304
+ onTap: () => onSelect(section),
305
+ ),
306
+ );
307
+ }
308
+ }
309
+ return widgets;
310
+ }
311
+
312
+ Future<void> _confirmDelete(
313
+ BuildContext context, {
314
+ required String title,
315
+ required String message,
316
+ required Future<void> Function() onConfirm,
317
+ String confirmLabel = 'Delete',
318
+ }) async {
319
+ final confirmed = await showDialog<bool>(
320
+ context: context,
321
+ builder: (context) {
322
+ return AlertDialog(
323
+ backgroundColor: _bgCard,
324
+ title: Text(title),
325
+ content: Text(message),
326
+ actions: <Widget>[
327
+ TextButton(
328
+ onPressed: () => Navigator.of(context).pop(false),
329
+ child: Text('Cancel'),
330
+ ),
331
+ FilledButton(
332
+ onPressed: () => Navigator.of(context).pop(true),
333
+ child: Text(confirmLabel),
334
+ ),
335
+ ],
336
+ );
337
+ },
338
+ );
339
+ if (confirmed == true) {
340
+ await onConfirm();
341
+ }
342
+ }
343
+
344
+ Widget _buildHealthSummaryPills(Map<String, dynamic> summary) {
345
+ return Wrap(
346
+ spacing: 10,
347
+ runSpacing: 10,
348
+ children: <Widget>[
349
+ _MetaPill(
350
+ icon: Icons.directions_walk_outlined,
351
+ label: 'Steps ${_asInt(summary['stepsTotal'])}',
352
+ ),
353
+ _MetaPill(
354
+ icon: Icons.favorite_outline,
355
+ label: 'Heart ${_asInt(summary['heartRateRecordCount'])} records',
356
+ ),
357
+ _MetaPill(
358
+ icon: Icons.bedtime_outlined,
359
+ label: 'Sleep ${_asInt(summary['sleepSessionCount'])} sessions',
360
+ ),
361
+ _MetaPill(
362
+ icon: Icons.fitness_center_outlined,
363
+ label: 'Exercise ${_asInt(summary['exerciseSessionCount'])} sessions',
364
+ ),
365
+ _MetaPill(
366
+ icon: Icons.monitor_weight_outlined,
367
+ label: 'Weight ${_asInt(summary['weightRecordCount'])} records',
368
+ ),
369
+ ],
370
+ );
371
+ }
372
+
373
+ class _PageTitle extends StatelessWidget {
374
+ const _PageTitle({
375
+ required this.title,
376
+ required this.subtitle,
377
+ this.trailing,
378
+ });
379
+
380
+ final String title;
381
+ final String subtitle;
382
+ final Widget? trailing;
383
+
384
+ @override
385
+ Widget build(BuildContext context) {
386
+ final compact = MediaQuery.sizeOf(context).width < 760;
387
+ final titleStyle = compact
388
+ ? _displayTitleStyle(26)
389
+ : _displayTitleStyle(32);
390
+ final subtitleStyle = TextStyle(
391
+ color: _textSecondary,
392
+ height: compact ? 1.38 : 1.5,
393
+ );
394
+ return _EntranceMotion(
395
+ child: Padding(
396
+ padding: EdgeInsets.only(bottom: compact ? 16 : 24),
397
+ child: compact
398
+ ? Column(
399
+ crossAxisAlignment: CrossAxisAlignment.start,
400
+ children: <Widget>[
401
+ Text('CONTROL SURFACE', style: _sectionEyebrowStyle()),
402
+ const SizedBox(height: 6),
403
+ Text(title, style: titleStyle),
404
+ const SizedBox(height: 8),
405
+ ConstrainedBox(
406
+ constraints: const BoxConstraints(maxWidth: 720),
407
+ child: Text(subtitle, style: subtitleStyle),
408
+ ),
409
+ if (trailing != null) ...<Widget>[
410
+ const SizedBox(height: 12),
411
+ trailing!,
412
+ ],
413
+ ],
414
+ )
415
+ : Row(
416
+ crossAxisAlignment: CrossAxisAlignment.start,
417
+ children: <Widget>[
418
+ Expanded(
419
+ child: Column(
420
+ crossAxisAlignment: CrossAxisAlignment.start,
421
+ children: <Widget>[
422
+ Text('CONTROL SURFACE', style: _sectionEyebrowStyle()),
423
+ const SizedBox(height: 8),
424
+ Text(title, style: titleStyle),
425
+ const SizedBox(height: 10),
426
+ ConstrainedBox(
427
+ constraints: const BoxConstraints(maxWidth: 760),
428
+ child: Text(subtitle, style: subtitleStyle),
429
+ ),
430
+ ],
431
+ ),
432
+ ),
433
+ if (trailing != null) trailing!,
434
+ ],
435
+ ),
436
+ ),
437
+ );
438
+ }
439
+ }
440
+
441
+ class _RunStatusPanel extends StatelessWidget {
442
+ const _RunStatusPanel({required this.run, required this.tools});
443
+
444
+ final ActiveRunState? run;
445
+ final List<ToolEventItem> tools;
446
+
447
+ @override
448
+ Widget build(BuildContext context) {
449
+ final runningCount = tools.where((tool) => tool.status == 'running').length;
450
+ final helperCount = tools.where((tool) => tool.isHelperRelated).length;
451
+ final webCount = tools.where((tool) => tool.isWebRelated).length;
452
+
453
+ return Card(
454
+ child: Padding(
455
+ padding: const EdgeInsets.all(18),
456
+ child: Column(
457
+ crossAxisAlignment: CrossAxisAlignment.start,
458
+ children: <Widget>[
459
+ Row(
460
+ children: <Widget>[
461
+ Expanded(
462
+ child: Column(
463
+ crossAxisAlignment: CrossAxisAlignment.start,
464
+ children: <Widget>[
465
+ Text(
466
+ run?.title ?? 'Live run',
467
+ style: TextStyle(
468
+ fontSize: 16,
469
+ fontWeight: FontWeight.w700,
470
+ ),
471
+ ),
472
+ const SizedBox(height: 6),
473
+ Text(
474
+ run == null
475
+ ? 'Waiting for run events...'
476
+ : [
477
+ '${run!.phase}${run!.iteration > 0 ? ' · step ${run!.iteration}' : ''}',
478
+ if (run!.pendingSteeringCount > 0)
479
+ '${run!.pendingSteeringCount} steering ${run!.pendingSteeringCount == 1 ? 'update' : 'updates'} queued',
480
+ ].join(' · '),
481
+ style: TextStyle(color: _textSecondary),
482
+ ),
483
+ ],
484
+ ),
485
+ ),
486
+ if (run != null && run!.model.isNotEmpty)
487
+ _MetaPill(label: run!.model, icon: Icons.memory_outlined),
488
+ ],
489
+ ),
490
+ if (tools.isNotEmpty) ...<Widget>[
491
+ const SizedBox(height: 14),
492
+ Wrap(
493
+ spacing: 10,
494
+ runSpacing: 10,
495
+ children: <Widget>[
496
+ _MetaPill(
497
+ label: '${tools.length} events',
498
+ icon: Icons.timeline_outlined,
499
+ ),
500
+ if (runningCount > 0)
501
+ _MetaPill(
502
+ label: '$runningCount active',
503
+ icon: Icons.sync_outlined,
504
+ color: _warning,
505
+ ),
506
+ if (webCount > 0)
507
+ _MetaPill(
508
+ label: '$webCount web',
509
+ icon: Icons.language_outlined,
510
+ ),
511
+ if (helperCount > 0)
512
+ _MetaPill(
513
+ label: '$helperCount helpers',
514
+ icon: Icons.account_tree_outlined,
515
+ ),
516
+ ],
517
+ ),
518
+ const SizedBox(height: 14),
519
+ ...tools.asMap().entries.map(
520
+ (entry) => Padding(
521
+ padding: EdgeInsets.only(
522
+ bottom: entry.key == tools.length - 1 ? 0 : 12,
523
+ ),
524
+ child: _ToolEventTimelineRow(
525
+ tool: entry.value,
526
+ isLast: entry.key == tools.length - 1,
527
+ ),
528
+ ),
529
+ ),
530
+ ] else
531
+ Text(
532
+ 'Waiting for task events...',
533
+ style: TextStyle(color: _textSecondary),
534
+ ),
535
+ ],
536
+ ),
537
+ ),
538
+ );
539
+ }
540
+ }
541
+
542
+ class _ToolEventTimelineRow extends StatelessWidget {
543
+ const _ToolEventTimelineRow({required this.tool, required this.isLast});
544
+
545
+ final ToolEventItem tool;
546
+ final bool isLast;
547
+
548
+ @override
549
+ Widget build(BuildContext context) {
550
+ Color color;
551
+ switch (tool.status) {
552
+ case 'running':
553
+ color = _warning;
554
+ break;
555
+ case 'failed':
556
+ color = _danger;
557
+ break;
558
+ default:
559
+ color = _success;
560
+ }
561
+ return Row(
562
+ crossAxisAlignment: CrossAxisAlignment.start,
563
+ children: <Widget>[
564
+ SizedBox(
565
+ width: 28,
566
+ child: Column(
567
+ children: <Widget>[
568
+ Container(
569
+ width: 28,
570
+ height: 28,
571
+ decoration: BoxDecoration(
572
+ color: color.withValues(alpha: 0.14),
573
+ shape: BoxShape.circle,
574
+ ),
575
+ child: Icon(tool.laneIcon, size: 16, color: color),
576
+ ),
577
+ if (!isLast)
578
+ Container(
579
+ width: 2,
580
+ height: 62,
581
+ margin: const EdgeInsets.only(top: 6),
582
+ decoration: BoxDecoration(
583
+ color: _border,
584
+ borderRadius: BorderRadius.circular(999),
585
+ ),
586
+ ),
587
+ ],
588
+ ),
589
+ ),
590
+ const SizedBox(width: 12),
591
+ Expanded(
592
+ child: Container(
593
+ padding: const EdgeInsets.all(12),
594
+ decoration: BoxDecoration(
595
+ color: _bgSecondary,
596
+ borderRadius: BorderRadius.circular(14),
597
+ border: Border.all(color: _border),
598
+ ),
599
+ child: Column(
600
+ crossAxisAlignment: CrossAxisAlignment.start,
601
+ children: <Widget>[
602
+ Row(
603
+ crossAxisAlignment: CrossAxisAlignment.start,
604
+ children: <Widget>[
605
+ Expanded(
606
+ child: Column(
607
+ crossAxisAlignment: CrossAxisAlignment.start,
608
+ children: <Widget>[
609
+ Text(
610
+ tool.toolName,
611
+ style: TextStyle(fontWeight: FontWeight.w700),
612
+ ),
613
+ const SizedBox(height: 4),
614
+ Text(
615
+ tool.laneLabel,
616
+ style: TextStyle(
617
+ color: _textSecondary,
618
+ fontSize: 12,
619
+ fontWeight: FontWeight.w600,
620
+ ),
621
+ ),
622
+ ],
623
+ ),
624
+ ),
625
+ _StatusPill(label: tool.statusLabel, color: color),
626
+ ],
627
+ ),
628
+ if (tool.summary.isNotEmpty) ...<Widget>[
629
+ const SizedBox(height: 8),
630
+ Text(
631
+ tool.compactSummary,
632
+ style: TextStyle(color: _textSecondary, height: 1.45),
633
+ ),
634
+ ],
635
+ ],
636
+ ),
637
+ ),
638
+ ),
639
+ ],
640
+ );
641
+ }
642
+ }
643
+
644
+ class _SettingToggle extends StatelessWidget {
645
+ const _SettingToggle({
646
+ required this.title,
647
+ required this.subtitle,
648
+ required this.value,
649
+ required this.onChanged,
650
+ });
651
+
652
+ final String title;
653
+ final String subtitle;
654
+ final bool value;
655
+ final ValueChanged<bool> onChanged;
656
+
657
+ @override
658
+ Widget build(BuildContext context) {
659
+ return SwitchListTile(
660
+ value: value,
661
+ contentPadding: EdgeInsets.zero,
662
+ title: Text(title),
663
+ subtitle: Text(subtitle),
664
+ onChanged: onChanged,
665
+ );
666
+ }
667
+ }
668
+
669
+ class _OverviewCard extends StatelessWidget {
670
+ const _OverviewCard({
671
+ required this.title,
672
+ required this.value,
673
+ required this.helper,
674
+ });
675
+
676
+ final String title;
677
+ final String value;
678
+ final String helper;
679
+
680
+ @override
681
+ Widget build(BuildContext context) {
682
+ return _EntranceMotion(
683
+ child: Card(
684
+ child: Padding(
685
+ padding: const EdgeInsets.all(22),
686
+ child: Column(
687
+ crossAxisAlignment: CrossAxisAlignment.start,
688
+ children: <Widget>[
689
+ Container(
690
+ width: 34,
691
+ height: 4,
692
+ decoration: BoxDecoration(
693
+ gradient: LinearGradient(
694
+ colors: <Color>[
695
+ _accentHover,
696
+ _accentAlt.withValues(alpha: 0.9),
697
+ ],
698
+ ),
699
+ borderRadius: BorderRadius.circular(999),
700
+ ),
701
+ ),
702
+ const SizedBox(height: 16),
703
+ Text(
704
+ title.toUpperCase(),
705
+ style: TextStyle(
706
+ color: _textSecondary,
707
+ fontSize: 11,
708
+ fontWeight: FontWeight.w700,
709
+ letterSpacing: 1.2,
710
+ ),
711
+ ),
712
+ const SizedBox(height: 10),
713
+ Text(value, style: _displayTitleStyle(28)),
714
+ const SizedBox(height: 12),
715
+ Text(
716
+ helper,
717
+ style: TextStyle(color: _textSecondary, height: 1.45),
718
+ ),
719
+ ],
720
+ ),
721
+ ),
722
+ ),
723
+ );
724
+ }
725
+ }
726
+
727
+ class _EmptyCard extends StatelessWidget {
728
+ const _EmptyCard({required this.title, required this.subtitle});
729
+
730
+ final String title;
731
+ final String subtitle;
732
+
733
+ @override
734
+ Widget build(BuildContext context) {
735
+ return _EntranceMotion(
736
+ child: Card(
737
+ child: Padding(
738
+ padding: const EdgeInsets.all(34),
739
+ child: _EmptyState(title: title, subtitle: subtitle),
740
+ ),
741
+ ),
742
+ );
743
+ }
744
+ }
745
+
746
+ class _SectionTitle extends StatelessWidget {
747
+ const _SectionTitle(this.text);
748
+
749
+ final String text;
750
+
751
+ @override
752
+ Widget build(BuildContext context) {
753
+ return Text(
754
+ text.toUpperCase(),
755
+ style: TextStyle(
756
+ fontSize: 12,
757
+ fontWeight: FontWeight.w800,
758
+ letterSpacing: 1.1,
759
+ color: _textSecondary,
760
+ ),
761
+ );
762
+ }
763
+ }
764
+
765
+ class _DotStatus extends StatelessWidget {
766
+ const _DotStatus({required this.label, required this.color});
767
+
768
+ final String label;
769
+ final Color color;
770
+
771
+ @override
772
+ Widget build(BuildContext context) {
773
+ return _GlassSurface(
774
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
775
+ borderRadius: BorderRadius.circular(999),
776
+ blurSigma: 16,
777
+ fillColor: _bgSecondary.withValues(alpha: 0.28),
778
+ borderColor: _glassBorder.withValues(alpha: 0.8),
779
+ child: Row(
780
+ mainAxisSize: MainAxisSize.min,
781
+ children: <Widget>[
782
+ Container(
783
+ width: 8,
784
+ height: 8,
785
+ decoration: BoxDecoration(color: color, shape: BoxShape.circle),
786
+ ),
787
+ const SizedBox(width: 8),
788
+ Text(label),
789
+ ],
790
+ ),
791
+ );
792
+ }
793
+ }
794
+
795
+ class _SidebarButton extends StatelessWidget {
796
+ const _SidebarButton({
797
+ required this.label,
798
+ required this.icon,
799
+ this.active = false,
800
+ this.indent = 0,
801
+ this.iconSize = 18,
802
+ this.fontSize = 13,
803
+ this.trailing,
804
+ required this.onTap,
805
+ });
806
+
807
+ final String label;
808
+ final IconData icon;
809
+ final bool active;
810
+ final double indent;
811
+ final double iconSize;
812
+ final double fontSize;
813
+ final Widget? trailing;
814
+ final VoidCallback? onTap;
815
+
816
+ @override
817
+ Widget build(BuildContext context) {
818
+ return Padding(
819
+ padding: const EdgeInsets.only(bottom: 6),
820
+ child: AnimatedScale(
821
+ duration: const Duration(milliseconds: 220),
822
+ curve: Curves.easeOutCubic,
823
+ scale: active ? 1.01 : 1,
824
+ child: _GlassSurface(
825
+ borderRadius: BorderRadius.circular(18),
826
+ blurSigma: 18,
827
+ fillColor: active
828
+ ? _accentMuted.withValues(alpha: 0.32)
829
+ : _bgCard.withValues(alpha: 0.2),
830
+ borderColor: active
831
+ ? _accent.withValues(alpha: 0.32)
832
+ : Colors.white.withValues(alpha: 0.03),
833
+ boxShadow: active
834
+ ? <BoxShadow>[
835
+ BoxShadow(
836
+ color: _accent.withValues(alpha: 0.12),
837
+ blurRadius: 22,
838
+ offset: const Offset(0, 8),
839
+ ),
840
+ ]
841
+ : null,
842
+ child: Material(
843
+ color: Colors.transparent,
844
+ child: InkWell(
845
+ borderRadius: BorderRadius.circular(18),
846
+ onTap: onTap,
847
+ child: Container(
848
+ width: double.infinity,
849
+ padding: EdgeInsets.fromLTRB(12 + indent, 12, 12, 12),
850
+ child: Row(
851
+ children: <Widget>[
852
+ if (active)
853
+ Container(
854
+ width: 6,
855
+ height: 26,
856
+ margin: const EdgeInsets.only(right: 10),
857
+ decoration: BoxDecoration(
858
+ gradient: LinearGradient(
859
+ colors: <Color>[
860
+ _accentHover,
861
+ _accentAlt.withValues(alpha: 0.9),
862
+ ],
863
+ begin: Alignment.topCenter,
864
+ end: Alignment.bottomCenter,
865
+ ),
866
+ borderRadius: BorderRadius.circular(999),
867
+ ),
868
+ ),
869
+ Icon(
870
+ icon,
871
+ size: iconSize,
872
+ color: active ? _accentHover : _textSecondary,
873
+ ),
874
+ const SizedBox(width: 10),
875
+ Expanded(
876
+ child: Text(
877
+ label,
878
+ style: TextStyle(
879
+ fontSize: fontSize,
880
+ fontWeight: active
881
+ ? FontWeight.w700
882
+ : FontWeight.w600,
883
+ color: active ? _textPrimary : _textSecondary,
884
+ ),
885
+ ),
886
+ ),
887
+ if (trailing != null) ...<Widget>[
888
+ const SizedBox(width: 8),
889
+ trailing!,
890
+ ],
891
+ ],
892
+ ),
893
+ ),
894
+ ),
895
+ ),
896
+ ),
897
+ ),
898
+ );
899
+ }
900
+ }
901
+
902
+ class _SidebarIconButton extends StatelessWidget {
903
+ const _SidebarIconButton({
904
+ required this.tooltip,
905
+ required this.icon,
906
+ required this.onTap,
907
+ });
908
+
909
+ final String tooltip;
910
+ final IconData icon;
911
+ final VoidCallback? onTap;
912
+
913
+ @override
914
+ Widget build(BuildContext context) {
915
+ return Tooltip(
916
+ message: tooltip,
917
+ child: _GlassSurface(
918
+ borderRadius: BorderRadius.circular(999),
919
+ blurSigma: 18,
920
+ fillColor: _bgCard.withValues(alpha: 0.3),
921
+ child: Material(
922
+ color: Colors.transparent,
923
+ shape: const CircleBorder(),
924
+ child: InkWell(
925
+ customBorder: const CircleBorder(),
926
+ onTap: onTap,
927
+ child: SizedBox(
928
+ width: 38,
929
+ height: 38,
930
+ child: Icon(icon, size: 17, color: _textSecondary),
931
+ ),
932
+ ),
933
+ ),
934
+ ),
935
+ );
936
+ }
937
+ }
938
+
939
+ class _BlurOrb extends StatelessWidget {
940
+ const _BlurOrb({required this.size, required this.color});
941
+
942
+ final double size;
943
+ final Color color;
944
+
945
+ @override
946
+ Widget build(BuildContext context) {
947
+ return IgnorePointer(
948
+ child: Container(
949
+ width: size,
950
+ height: size,
951
+ decoration: BoxDecoration(
952
+ shape: BoxShape.circle,
953
+ boxShadow: <BoxShadow>[
954
+ BoxShadow(
955
+ color: color.withValues(alpha: 0.18),
956
+ blurRadius: 120,
957
+ spreadRadius: 30,
958
+ ),
959
+ ],
960
+ ),
961
+ ),
962
+ );
963
+ }
964
+ }
965
+
966
+ class _LogoBadge extends StatelessWidget {
967
+ const _LogoBadge({required this.size});
968
+
969
+ final double size;
970
+
971
+ @override
972
+ Widget build(BuildContext context) {
973
+ return Container(
974
+ width: size,
975
+ height: size,
976
+ decoration: BoxDecoration(
977
+ gradient: LinearGradient(
978
+ colors: <Color>[_brandAccent, _brandAccentAlt],
979
+ begin: Alignment.topLeft,
980
+ end: Alignment.bottomRight,
981
+ ),
982
+ borderRadius: BorderRadius.circular(size * 0.34),
983
+ boxShadow: <BoxShadow>[
984
+ BoxShadow(
985
+ color: _brandAccent.withValues(alpha: 0.32),
986
+ blurRadius: 36,
987
+ offset: const Offset(0, 10),
988
+ ),
989
+ ],
990
+ border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
991
+ ),
992
+ child: Padding(
993
+ padding: EdgeInsets.all(size * 0.18),
994
+ child: CustomPaint(painter: _NeoAgentLogoPainter()),
995
+ ),
996
+ );
997
+ }
998
+ }
999
+
1000
+ class _BrandLockup extends StatelessWidget {
1001
+ const _BrandLockup({
1002
+ required this.logoSize,
1003
+ this.titleFontSize = 28,
1004
+ this.direction = Axis.vertical,
1005
+ this.spacing = 18,
1006
+ this.alignment = CrossAxisAlignment.center,
1007
+ });
1008
+
1009
+ final double logoSize;
1010
+ final double titleFontSize;
1011
+ final Axis direction;
1012
+ final double spacing;
1013
+ final CrossAxisAlignment alignment;
1014
+
1015
+ @override
1016
+ Widget build(BuildContext context) {
1017
+ final title = Text(
1018
+ 'NeoOS',
1019
+ style: GoogleFonts.spaceGrotesk(
1020
+ fontSize: titleFontSize,
1021
+ fontWeight: FontWeight.w700,
1022
+ color: _textPrimary,
1023
+ letterSpacing: -0.4,
1024
+ ),
1025
+ );
1026
+
1027
+ if (direction == Axis.horizontal) {
1028
+ return Row(
1029
+ mainAxisSize: MainAxisSize.min,
1030
+ children: <Widget>[
1031
+ _LogoBadge(size: logoSize),
1032
+ SizedBox(width: spacing),
1033
+ Flexible(child: title),
1034
+ ],
1035
+ );
1036
+ }
1037
+
1038
+ return Column(
1039
+ mainAxisSize: MainAxisSize.min,
1040
+ crossAxisAlignment: alignment,
1041
+ children: <Widget>[
1042
+ _LogoBadge(size: logoSize),
1043
+ SizedBox(height: spacing),
1044
+ title,
1045
+ ],
1046
+ );
1047
+ }
1048
+ }
1049
+
1050
+ class _NeoAgentLogoPainter extends CustomPainter {
1051
+ @override
1052
+ void paint(Canvas canvas, Size size) {
1053
+ final fillPaint = Paint()
1054
+ ..color = Colors.white
1055
+ ..style = PaintingStyle.fill;
1056
+ final strokePaint = Paint()
1057
+ ..color = Colors.white
1058
+ ..style = PaintingStyle.stroke
1059
+ ..strokeWidth = size.width * 0.08
1060
+ ..strokeCap = StrokeCap.round
1061
+ ..strokeJoin = StrokeJoin.round;
1062
+
1063
+ final top = Path()
1064
+ ..moveTo(size.width * 0.5, size.height * 0.08)
1065
+ ..lineTo(size.width * 0.1, size.height * 0.3)
1066
+ ..lineTo(size.width * 0.5, size.height * 0.52)
1067
+ ..lineTo(size.width * 0.9, size.height * 0.3)
1068
+ ..close();
1069
+ canvas.drawPath(top, fillPaint);
1070
+
1071
+ final middle = Path()
1072
+ ..moveTo(size.width * 0.1, size.height * 0.52)
1073
+ ..lineTo(size.width * 0.5, size.height * 0.74)
1074
+ ..lineTo(size.width * 0.9, size.height * 0.52);
1075
+ canvas.drawPath(middle, strokePaint);
1076
+
1077
+ final bottom = Path()
1078
+ ..moveTo(size.width * 0.1, size.height * 0.72)
1079
+ ..lineTo(size.width * 0.5, size.height * 0.94)
1080
+ ..lineTo(size.width * 0.9, size.height * 0.72);
1081
+ canvas.drawPath(bottom, strokePaint);
1082
+ }
1083
+
1084
+ @override
1085
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
1086
+ }
1087
+
1088
+ class _EmptyState extends StatelessWidget {
1089
+ const _EmptyState({required this.title, required this.subtitle});
1090
+
1091
+ final String title;
1092
+ final String subtitle;
1093
+
1094
+ @override
1095
+ Widget build(BuildContext context) {
1096
+ return Column(
1097
+ mainAxisSize: MainAxisSize.min,
1098
+ children: <Widget>[
1099
+ const _LogoBadge(size: 52),
1100
+ const SizedBox(height: 12),
1101
+ Text(
1102
+ title,
1103
+ style: TextStyle(
1104
+ fontSize: 17,
1105
+ fontWeight: FontWeight.w600,
1106
+ color: _textPrimary,
1107
+ ),
1108
+ ),
1109
+ const SizedBox(height: 8),
1110
+ ConstrainedBox(
1111
+ constraints: const BoxConstraints(maxWidth: 360),
1112
+ child: Text(
1113
+ subtitle,
1114
+ textAlign: TextAlign.center,
1115
+ style: TextStyle(fontSize: 13, color: _textMuted),
1116
+ ),
1117
+ ),
1118
+ ],
1119
+ );
1120
+ }
1121
+ }
1122
+
1123
+ class _ChatBubble extends StatelessWidget {
1124
+ const _ChatBubble({required this.entry, this.onLoadRunDetail});
1125
+
1126
+ final ChatEntry entry;
1127
+ final Future<RunDetailSnapshot> Function(String runId)? onLoadRunDetail;
1128
+
1129
+ @override
1130
+ Widget build(BuildContext context) {
1131
+ final isUser = entry.role == 'user';
1132
+ final isTransient = entry.transient;
1133
+ final sharedAttachments = (entry.metadata['sharedAttachments'] is List)
1134
+ ? (entry.metadata['sharedAttachments'] as List)
1135
+ .whereType<Map>()
1136
+ .map((item) => SharedChatAttachment.fromJson(item))
1137
+ .where((item) => item.isValid)
1138
+ .toList(growable: false)
1139
+ : const <SharedChatAttachment>[];
1140
+
1141
+ if (entry.typing) {
1142
+ return const _TypingIndicatorBubble();
1143
+ }
1144
+
1145
+ return Opacity(
1146
+ opacity: isTransient ? 0.92 : 1,
1147
+ child: Row(
1148
+ crossAxisAlignment: CrossAxisAlignment.start,
1149
+ mainAxisAlignment: isUser
1150
+ ? MainAxisAlignment.end
1151
+ : MainAxisAlignment.start,
1152
+ children: <Widget>[
1153
+ if (!isUser) ...<Widget>[
1154
+ const _MessageAvatar(assistant: true),
1155
+ const SizedBox(width: 12),
1156
+ ],
1157
+ Flexible(
1158
+ child: Container(
1159
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
1160
+ decoration: BoxDecoration(
1161
+ color: isUser ? _accent : _bgCard,
1162
+ borderRadius: BorderRadius.only(
1163
+ topLeft: const Radius.circular(14),
1164
+ topRight: const Radius.circular(14),
1165
+ bottomLeft: Radius.circular(isUser ? 14 : 4),
1166
+ bottomRight: Radius.circular(isUser ? 4 : 14),
1167
+ ),
1168
+ border: isUser ? null : Border.all(color: _border),
1169
+ boxShadow: isUser
1170
+ ? <BoxShadow>[
1171
+ BoxShadow(
1172
+ color: _accentAlt.withValues(alpha: 0.30),
1173
+ blurRadius: 12,
1174
+ offset: const Offset(0, 2),
1175
+ ),
1176
+ ]
1177
+ : null,
1178
+ ),
1179
+ child: Column(
1180
+ crossAxisAlignment: isUser
1181
+ ? CrossAxisAlignment.end
1182
+ : CrossAxisAlignment.start,
1183
+ children: <Widget>[
1184
+ if (!isUser && entry.platformTag != null)
1185
+ Padding(
1186
+ padding: const EdgeInsets.only(bottom: 8),
1187
+ child: _StatusPill(
1188
+ label: entry.platformTag!,
1189
+ color: entry.platform == 'live' ? _info : _warning,
1190
+ ),
1191
+ ),
1192
+ MarkdownBody(
1193
+ data: entry.content,
1194
+ selectable: false,
1195
+ styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context))
1196
+ .copyWith(
1197
+ p: Theme.of(context).textTheme.bodyMedium?.copyWith(
1198
+ color: isUser ? Colors.white : _textPrimary,
1199
+ height: 1.65,
1200
+ ),
1201
+ code: Theme.of(context).textTheme.bodyMedium
1202
+ ?.copyWith(
1203
+ fontFamily:
1204
+ GoogleFonts.jetBrainsMono().fontFamily,
1205
+ backgroundColor: _bgPrimary,
1206
+ color: isUser ? Colors.white : _textPrimary,
1207
+ ),
1208
+ blockquoteDecoration: BoxDecoration(
1209
+ borderRadius: BorderRadius.circular(14),
1210
+ color: const Color(0x22000000),
1211
+ ),
1212
+ ),
1213
+ ),
1214
+ if (sharedAttachments.isNotEmpty) ...<Widget>[
1215
+ const SizedBox(height: 10),
1216
+ Wrap(
1217
+ spacing: 8,
1218
+ runSpacing: 8,
1219
+ children: sharedAttachments
1220
+ .map((attachment) {
1221
+ final icon =
1222
+ attachment.mimeType.toLowerCase().startsWith(
1223
+ 'video/',
1224
+ )
1225
+ ? Icons.videocam_outlined
1226
+ : attachment.mimeType.toLowerCase().startsWith(
1227
+ 'image/',
1228
+ )
1229
+ ? Icons.image_outlined
1230
+ : attachment.mimeType.toLowerCase().startsWith(
1231
+ 'audio/',
1232
+ )
1233
+ ? Icons.audiotrack_outlined
1234
+ : Icons.attach_file_rounded;
1235
+ return Container(
1236
+ padding: const EdgeInsets.symmetric(
1237
+ horizontal: 10,
1238
+ vertical: 7,
1239
+ ),
1240
+ decoration: BoxDecoration(
1241
+ color: isUser
1242
+ ? const Color(0x1FFFFFFF)
1243
+ : _bgSecondary,
1244
+ borderRadius: BorderRadius.circular(999),
1245
+ border: Border.all(
1246
+ color: isUser
1247
+ ? const Color(0x40FFFFFF)
1248
+ : _border,
1249
+ ),
1250
+ ),
1251
+ child: Row(
1252
+ mainAxisSize: MainAxisSize.min,
1253
+ children: <Widget>[
1254
+ Icon(
1255
+ icon,
1256
+ size: 14,
1257
+ color: isUser
1258
+ ? Colors.white
1259
+ : _textSecondary,
1260
+ ),
1261
+ const SizedBox(width: 6),
1262
+ ConstrainedBox(
1263
+ constraints: const BoxConstraints(
1264
+ maxWidth: 180,
1265
+ ),
1266
+ child: Text(
1267
+ attachment.name,
1268
+ maxLines: 1,
1269
+ overflow: TextOverflow.ellipsis,
1270
+ style: TextStyle(
1271
+ color: isUser
1272
+ ? Colors.white
1273
+ : _textPrimary,
1274
+ fontSize: 12,
1275
+ ),
1276
+ ),
1277
+ ),
1278
+ ],
1279
+ ),
1280
+ );
1281
+ })
1282
+ .toList(growable: false),
1283
+ ),
1284
+ ],
1285
+ if (!isUser &&
1286
+ entry.runId?.trim().isNotEmpty == true) ...<Widget>[
1287
+ const SizedBox(height: 12),
1288
+ _MessageRunPreview(
1289
+ runId: entry.runId!.trim(),
1290
+ onLoadRunDetail: onLoadRunDetail,
1291
+ ),
1292
+ ],
1293
+ const SizedBox(height: 10),
1294
+ Text(
1295
+ entry.createdAtLabel,
1296
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
1297
+ color: isUser ? const Color(0xCCFFFFFF) : _textSecondary,
1298
+ ),
1299
+ ),
1300
+ ],
1301
+ ),
1302
+ ),
1303
+ ),
1304
+ if (isUser) ...<Widget>[
1305
+ const SizedBox(width: 12),
1306
+ const _MessageAvatar(assistant: false),
1307
+ ],
1308
+ ],
1309
+ ),
1310
+ );
1311
+ }
1312
+ }
1313
+
1314
+ class _MessageRunPreview extends StatefulWidget {
1315
+ const _MessageRunPreview({
1316
+ required this.runId,
1317
+ required this.onLoadRunDetail,
1318
+ });
1319
+
1320
+ final String runId;
1321
+ final Future<RunDetailSnapshot> Function(String runId)? onLoadRunDetail;
1322
+
1323
+ @override
1324
+ State<_MessageRunPreview> createState() => _MessageRunPreviewState();
1325
+ }
1326
+
1327
+ class _MessageRunPreviewState extends State<_MessageRunPreview> {
1328
+ late Future<RunDetailSnapshot>? _future;
1329
+
1330
+ @override
1331
+ void initState() {
1332
+ super.initState();
1333
+ _future = widget.onLoadRunDetail?.call(widget.runId);
1334
+ }
1335
+
1336
+ @override
1337
+ void didUpdateWidget(covariant _MessageRunPreview oldWidget) {
1338
+ super.didUpdateWidget(oldWidget);
1339
+ if (oldWidget.runId != widget.runId ||
1340
+ oldWidget.onLoadRunDetail != widget.onLoadRunDetail) {
1341
+ _future = widget.onLoadRunDetail?.call(widget.runId);
1342
+ }
1343
+ }
1344
+
1345
+ @override
1346
+ Widget build(BuildContext context) {
1347
+ if (_future == null) {
1348
+ return const SizedBox.shrink();
1349
+ }
1350
+ return FutureBuilder<RunDetailSnapshot>(
1351
+ future: _future,
1352
+ builder: (context, snapshot) {
1353
+ if (snapshot.connectionState == ConnectionState.waiting) {
1354
+ return _MessageRunCardShell(
1355
+ child: Row(
1356
+ children: <Widget>[
1357
+ SizedBox.square(
1358
+ dimension: 14,
1359
+ child: CircularProgressIndicator(strokeWidth: 2),
1360
+ ),
1361
+ const SizedBox(width: 10),
1362
+ Expanded(
1363
+ child: Text(
1364
+ 'Loading execution details...',
1365
+ style: TextStyle(color: _textSecondary, fontSize: 12),
1366
+ ),
1367
+ ),
1368
+ ],
1369
+ ),
1370
+ );
1371
+ }
1372
+ if (snapshot.hasError || !snapshot.hasData) {
1373
+ return const SizedBox.shrink();
1374
+ }
1375
+ final detail = snapshot.data!;
1376
+ final previewSteps = detail.steps.take(4).toList();
1377
+ return _MessageRunCardShell(
1378
+ child: Column(
1379
+ crossAxisAlignment: CrossAxisAlignment.start,
1380
+ children: <Widget>[
1381
+ Row(
1382
+ children: <Widget>[
1383
+ Expanded(
1384
+ child: Text(
1385
+ detail.run.title.ifEmpty('Execution'),
1386
+ style: TextStyle(
1387
+ fontWeight: FontWeight.w700,
1388
+ color: _textPrimary,
1389
+ ),
1390
+ ),
1391
+ ),
1392
+ _StatusPill(
1393
+ label: detail.run.statusLabel,
1394
+ color: detail.run.statusColor,
1395
+ ),
1396
+ ],
1397
+ ),
1398
+ const SizedBox(height: 10),
1399
+ Wrap(
1400
+ spacing: 8,
1401
+ runSpacing: 8,
1402
+ children: <Widget>[
1403
+ _MetaPill(
1404
+ label: '${detail.steps.length} steps',
1405
+ icon: Icons.timeline_outlined,
1406
+ ),
1407
+ if (detail.webStepCount > 0)
1408
+ _MetaPill(
1409
+ label: '${detail.webStepCount} web',
1410
+ icon: Icons.language_outlined,
1411
+ ),
1412
+ if (detail.helperCount > 0)
1413
+ _MetaPill(
1414
+ label: '${detail.helperCount} helpers',
1415
+ icon: Icons.account_tree_outlined,
1416
+ ),
1417
+ if (detail.planningStepCount > 0)
1418
+ _MetaPill(
1419
+ label: '${detail.planningStepCount} planning',
1420
+ icon: Icons.route_outlined,
1421
+ ),
1422
+ ],
1423
+ ),
1424
+ const SizedBox(height: 12),
1425
+ ...previewSteps.asMap().entries.map(
1426
+ (entry) => Padding(
1427
+ padding: EdgeInsets.only(
1428
+ bottom: entry.key == previewSteps.length - 1 ? 0 : 10,
1429
+ ),
1430
+ child: _MessageRunStepRow(
1431
+ step: entry.value,
1432
+ isLast: entry.key == previewSteps.length - 1,
1433
+ ),
1434
+ ),
1435
+ ),
1436
+ if (detail.steps.length > previewSteps.length) ...<Widget>[
1437
+ const SizedBox(height: 10),
1438
+ Text(
1439
+ '${detail.steps.length - previewSteps.length} more steps in run history',
1440
+ style: TextStyle(
1441
+ color: _textSecondary,
1442
+ fontSize: 12,
1443
+ fontWeight: FontWeight.w600,
1444
+ ),
1445
+ ),
1446
+ ],
1447
+ ],
1448
+ ),
1449
+ );
1450
+ },
1451
+ );
1452
+ }
1453
+ }
1454
+
1455
+ class _MessageRunCardShell extends StatelessWidget {
1456
+ const _MessageRunCardShell({required this.child});
1457
+
1458
+ final Widget child;
1459
+
1460
+ @override
1461
+ Widget build(BuildContext context) {
1462
+ return _GlassSurface(
1463
+ padding: const EdgeInsets.all(12),
1464
+ borderRadius: BorderRadius.circular(14),
1465
+ blurSigma: 18,
1466
+ fillColor: _bgPrimary.withValues(alpha: 0.34),
1467
+ child: child,
1468
+ );
1469
+ }
1470
+ }
1471
+
1472
+ class _MessageRunStepRow extends StatelessWidget {
1473
+ const _MessageRunStepRow({required this.step, required this.isLast});
1474
+
1475
+ final RunStepItem step;
1476
+ final bool isLast;
1477
+
1478
+ @override
1479
+ Widget build(BuildContext context) {
1480
+ return Row(
1481
+ crossAxisAlignment: CrossAxisAlignment.start,
1482
+ children: <Widget>[
1483
+ SizedBox(
1484
+ width: 24,
1485
+ child: Column(
1486
+ children: <Widget>[
1487
+ Container(
1488
+ width: 24,
1489
+ height: 24,
1490
+ decoration: BoxDecoration(
1491
+ color: step.statusColor.withValues(alpha: 0.14),
1492
+ shape: BoxShape.circle,
1493
+ ),
1494
+ child: Icon(step.laneIcon, size: 14, color: step.statusColor),
1495
+ ),
1496
+ if (!isLast)
1497
+ Container(
1498
+ width: 2,
1499
+ height: 34,
1500
+ margin: const EdgeInsets.only(top: 6),
1501
+ decoration: BoxDecoration(
1502
+ color: _border,
1503
+ borderRadius: BorderRadius.circular(999),
1504
+ ),
1505
+ ),
1506
+ ],
1507
+ ),
1508
+ ),
1509
+ const SizedBox(width: 10),
1510
+ Expanded(
1511
+ child: Column(
1512
+ crossAxisAlignment: CrossAxisAlignment.start,
1513
+ children: <Widget>[
1514
+ Row(
1515
+ children: <Widget>[
1516
+ Expanded(
1517
+ child: Text(
1518
+ step.label,
1519
+ style: TextStyle(
1520
+ fontWeight: FontWeight.w600,
1521
+ color: _textPrimary,
1522
+ ),
1523
+ ),
1524
+ ),
1525
+ Text(
1526
+ step.laneLabel,
1527
+ style: TextStyle(
1528
+ color: _textSecondary,
1529
+ fontSize: 11,
1530
+ fontWeight: FontWeight.w700,
1531
+ ),
1532
+ ),
1533
+ ],
1534
+ ),
1535
+ const SizedBox(height: 3),
1536
+ Text(
1537
+ step.compactSummary,
1538
+ style: TextStyle(
1539
+ color: _textSecondary,
1540
+ fontSize: 12,
1541
+ height: 1.4,
1542
+ ),
1543
+ ),
1544
+ ],
1545
+ ),
1546
+ ),
1547
+ ],
1548
+ );
1549
+ }
1550
+ }
1551
+
1552
+ class _MessageAvatar extends StatelessWidget {
1553
+ const _MessageAvatar({required this.assistant});
1554
+
1555
+ final bool assistant;
1556
+
1557
+ @override
1558
+ Widget build(BuildContext context) {
1559
+ return Container(
1560
+ width: 30,
1561
+ height: 30,
1562
+ decoration: BoxDecoration(
1563
+ borderRadius: BorderRadius.circular(8),
1564
+ gradient: assistant
1565
+ ? LinearGradient(colors: <Color>[_accent, _accentAlt])
1566
+ : null,
1567
+ color: assistant ? null : _bgTertiary,
1568
+ boxShadow: assistant
1569
+ ? <BoxShadow>[
1570
+ BoxShadow(
1571
+ color: _accentAlt.withValues(alpha: 0.35),
1572
+ blurRadius: 10,
1573
+ offset: const Offset(0, 2),
1574
+ ),
1575
+ ]
1576
+ : null,
1577
+ ),
1578
+ child: Icon(
1579
+ assistant ? Icons.auto_awesome : Icons.person,
1580
+ size: 16,
1581
+ color: assistant ? Colors.white : _textSecondary,
1582
+ ),
1583
+ );
1584
+ }
1585
+ }
1586
+
1587
+ class _StatusPill extends StatelessWidget {
1588
+ const _StatusPill({required this.label, required this.color});
1589
+
1590
+ final String label;
1591
+ final Color color;
1592
+
1593
+ @override
1594
+ Widget build(BuildContext context) {
1595
+ return AnimatedContainer(
1596
+ duration: const Duration(milliseconds: 180),
1597
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
1598
+ decoration: BoxDecoration(
1599
+ borderRadius: BorderRadius.circular(999),
1600
+ color: color.withValues(alpha: 0.14),
1601
+ border: Border.all(color: color.withValues(alpha: 0.18)),
1602
+ ),
1603
+ child: Text(
1604
+ label,
1605
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
1606
+ color: color,
1607
+ fontWeight: FontWeight.w700,
1608
+ ),
1609
+ ),
1610
+ );
1611
+ }
1612
+ }
1613
+
1614
+ class _MetaPill extends StatelessWidget {
1615
+ const _MetaPill({required this.label, required this.icon, this.color});
1616
+
1617
+ final String label;
1618
+ final IconData icon;
1619
+ final Color? color;
1620
+
1621
+ @override
1622
+ Widget build(BuildContext context) {
1623
+ final accentColor = color ?? _accentAlt;
1624
+ return _GlassSurface(
1625
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1626
+ borderRadius: BorderRadius.circular(999),
1627
+ blurSigma: 10,
1628
+ fillColor: _bgCard.withValues(alpha: 0.86),
1629
+ borderColor: _borderLight,
1630
+ child: Row(
1631
+ mainAxisSize: MainAxisSize.min,
1632
+ children: <Widget>[
1633
+ Icon(icon, size: 14, color: accentColor),
1634
+ const SizedBox(width: 8),
1635
+ Flexible(
1636
+ child: Text(
1637
+ label,
1638
+ overflow: TextOverflow.ellipsis,
1639
+ softWrap: false,
1640
+ ),
1641
+ ),
1642
+ ],
1643
+ ),
1644
+ );
1645
+ }
1646
+ }
1647
+
1648
+ class _InfoChip extends StatelessWidget {
1649
+ const _InfoChip({required this.icon, required this.label});
1650
+
1651
+ final IconData icon;
1652
+ final String label;
1653
+
1654
+ @override
1655
+ Widget build(BuildContext context) {
1656
+ return _GlassSurface(
1657
+ width: double.infinity,
1658
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
1659
+ borderRadius: BorderRadius.circular(16),
1660
+ blurSigma: 12,
1661
+ fillColor: _bgCard.withValues(alpha: 0.72),
1662
+ borderColor: _borderLight,
1663
+ child: Row(
1664
+ children: <Widget>[
1665
+ Icon(icon, size: 16, color: Colors.white.withValues(alpha: 0.72)),
1666
+ const SizedBox(width: 8),
1667
+ Expanded(
1668
+ child: Text(
1669
+ label,
1670
+ style: TextStyle(
1671
+ color: Colors.white.withValues(alpha: 0.82),
1672
+ fontSize: 13,
1673
+ ),
1674
+ ),
1675
+ ),
1676
+ ],
1677
+ ),
1678
+ );
1679
+ }
1680
+ }
1681
+
1682
+ class _InlineError extends StatelessWidget {
1683
+ const _InlineError({required this.message});
1684
+
1685
+ final String message;
1686
+
1687
+ @override
1688
+ Widget build(BuildContext context) {
1689
+ return Container(
1690
+ width: double.infinity,
1691
+ padding: const EdgeInsets.all(12),
1692
+ decoration: BoxDecoration(
1693
+ color: _danger.withValues(alpha: 0.10),
1694
+ borderRadius: BorderRadius.circular(AppRadius.tag),
1695
+ border: Border.all(color: _danger.withValues(alpha: 0.30)),
1696
+ ),
1697
+ child: Text(message, style: TextStyle(color: _danger)),
1698
+ );
1699
+ }
1700
+ }
1701
+
1702
+ class _InlineSuccess extends StatelessWidget {
1703
+ const _InlineSuccess({required this.message});
1704
+
1705
+ final String message;
1706
+
1707
+ @override
1708
+ Widget build(BuildContext context) {
1709
+ return Container(
1710
+ width: double.infinity,
1711
+ padding: const EdgeInsets.all(12),
1712
+ decoration: BoxDecoration(
1713
+ color: _success.withValues(alpha: 0.10),
1714
+ borderRadius: BorderRadius.circular(8),
1715
+ border: Border.all(color: _success.withValues(alpha: 0.28)),
1716
+ ),
1717
+ child: Row(
1718
+ children: <Widget>[
1719
+ Icon(Icons.check_circle_outline, color: _success, size: 18),
1720
+ const SizedBox(width: 8),
1721
+ Expanded(
1722
+ child: Text(
1723
+ message,
1724
+ style: TextStyle(color: _success, fontSize: 13),
1725
+ ),
1726
+ ),
1727
+ ],
1728
+ ),
1729
+ );
1730
+ }
1731
+ }
1732
+
1733
+ class _GlobalNetworkBanner extends StatelessWidget {
1734
+ const _GlobalNetworkBanner({required this.controller});
1735
+
1736
+ final NeoAgentController controller;
1737
+
1738
+ @override
1739
+ Widget build(BuildContext context) {
1740
+ return LayoutBuilder(
1741
+ builder: (context, constraints) {
1742
+ final compact = constraints.maxWidth < 520;
1743
+ return Material(
1744
+ color: Colors.transparent,
1745
+ child: Container(
1746
+ width: double.infinity,
1747
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
1748
+ decoration: BoxDecoration(
1749
+ color: _warning.withValues(alpha: 0.14),
1750
+ borderRadius: BorderRadius.circular(18),
1751
+ border: Border.all(color: _warning.withValues(alpha: 0.32)),
1752
+ boxShadow: <BoxShadow>[
1753
+ BoxShadow(
1754
+ color: Colors.black.withValues(alpha: 0.12),
1755
+ blurRadius: 18,
1756
+ offset: const Offset(0, 10),
1757
+ ),
1758
+ ],
1759
+ ),
1760
+ child: compact
1761
+ ? Column(
1762
+ crossAxisAlignment: CrossAxisAlignment.start,
1763
+ children: <Widget>[
1764
+ Row(
1765
+ children: <Widget>[
1766
+ Icon(
1767
+ Icons.cloud_off_outlined,
1768
+ color: _warning,
1769
+ size: 18,
1770
+ ),
1771
+ const SizedBox(width: 10),
1772
+ Expanded(
1773
+ child: Text(
1774
+ controller.offlineBannerMessage,
1775
+ style: TextStyle(
1776
+ color: _textPrimary,
1777
+ height: 1.35,
1778
+ ),
1779
+ ),
1780
+ ),
1781
+ ],
1782
+ ),
1783
+ const SizedBox(height: 10),
1784
+ OutlinedButton(
1785
+ onPressed: controller.refreshConnectivityStatus,
1786
+ style: OutlinedButton.styleFrom(
1787
+ foregroundColor: _textPrimary,
1788
+ side: BorderSide(
1789
+ color: _warning.withValues(alpha: 0.38),
1790
+ ),
1791
+ ),
1792
+ child: const Text('Retry'),
1793
+ ),
1794
+ ],
1795
+ )
1796
+ : Row(
1797
+ children: <Widget>[
1798
+ Icon(Icons.cloud_off_outlined, color: _warning, size: 18),
1799
+ const SizedBox(width: 10),
1800
+ Expanded(
1801
+ child: Text(
1802
+ controller.offlineBannerMessage,
1803
+ style: TextStyle(color: _textPrimary, height: 1.35),
1804
+ ),
1805
+ ),
1806
+ const SizedBox(width: 12),
1807
+ OutlinedButton(
1808
+ onPressed: controller.refreshConnectivityStatus,
1809
+ style: OutlinedButton.styleFrom(
1810
+ foregroundColor: _textPrimary,
1811
+ side: BorderSide(
1812
+ color: _warning.withValues(alpha: 0.38),
1813
+ ),
1814
+ ),
1815
+ child: const Text('Retry'),
1816
+ ),
1817
+ ],
1818
+ ),
1819
+ ),
1820
+ );
1821
+ },
1822
+ );
1823
+ }
1824
+ }
1825
+
1826
+ class _GlobalWebUpdateBanner extends StatelessWidget {
1827
+ const _GlobalWebUpdateBanner({required this.monitor});
1828
+
1829
+ final WebAppUpdateMonitor monitor;
1830
+
1831
+ @override
1832
+ Widget build(BuildContext context) {
1833
+ return LayoutBuilder(
1834
+ builder: (context, constraints) {
1835
+ final compact = constraints.maxWidth < 560;
1836
+ final content = Text(
1837
+ 'A newer web build is available on the server. Reload to fetch the latest bundle.',
1838
+ style: TextStyle(color: _textPrimary, height: 1.35),
1839
+ );
1840
+ return Material(
1841
+ color: Colors.transparent,
1842
+ child: Container(
1843
+ width: double.infinity,
1844
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
1845
+ decoration: BoxDecoration(
1846
+ color: _accent.withValues(alpha: 0.12),
1847
+ borderRadius: BorderRadius.circular(18),
1848
+ border: Border.all(color: _accent.withValues(alpha: 0.3)),
1849
+ boxShadow: <BoxShadow>[
1850
+ BoxShadow(
1851
+ color: Colors.black.withValues(alpha: 0.12),
1852
+ blurRadius: 18,
1853
+ offset: const Offset(0, 10),
1854
+ ),
1855
+ ],
1856
+ ),
1857
+ child: compact
1858
+ ? Column(
1859
+ crossAxisAlignment: CrossAxisAlignment.start,
1860
+ children: <Widget>[
1861
+ Row(
1862
+ crossAxisAlignment: CrossAxisAlignment.start,
1863
+ children: <Widget>[
1864
+ Icon(
1865
+ Icons.system_update_alt,
1866
+ color: _accent,
1867
+ size: 18,
1868
+ ),
1869
+ const SizedBox(width: 10),
1870
+ Expanded(child: content),
1871
+ ],
1872
+ ),
1873
+ const SizedBox(height: 10),
1874
+ FilledButton(
1875
+ onPressed: monitor.isReloading
1876
+ ? null
1877
+ : monitor.reloadToLatest,
1878
+ child: Text(
1879
+ monitor.isReloading ? 'Reloading...' : 'Reload now',
1880
+ ),
1881
+ ),
1882
+ if (monitor.isReloading) ...<Widget>[
1883
+ const SizedBox(height: 8),
1884
+ ClipRRect(
1885
+ borderRadius: BorderRadius.circular(999),
1886
+ child: const LinearProgressIndicator(minHeight: 3),
1887
+ ),
1888
+ ],
1889
+ ],
1890
+ )
1891
+ : Row(
1892
+ children: <Widget>[
1893
+ Icon(Icons.system_update_alt, color: _accent, size: 18),
1894
+ const SizedBox(width: 10),
1895
+ Expanded(child: content),
1896
+ const SizedBox(width: 12),
1897
+ FilledButton(
1898
+ onPressed: monitor.isReloading
1899
+ ? null
1900
+ : monitor.reloadToLatest,
1901
+ child: Text(
1902
+ monitor.isReloading ? 'Reloading...' : 'Reload now',
1903
+ ),
1904
+ ),
1905
+ ],
1906
+ ),
1907
+ ),
1908
+ );
1909
+ },
1910
+ );
1911
+ }
1912
+ }
1913
+
1914
+ class _GlobalAnalyticsConsentBanner extends StatelessWidget {
1915
+ const _GlobalAnalyticsConsentBanner({required this.controller});
1916
+
1917
+ final NeoAgentController controller;
1918
+
1919
+ @override
1920
+ Widget build(BuildContext context) {
1921
+ return LayoutBuilder(
1922
+ builder: (context, constraints) {
1923
+ final compact = constraints.maxWidth < 700;
1924
+ final body = Text(
1925
+ 'NeoAgent uses anonymous analytics to understand startup, navigation, and setup flows. No messages, credentials, or personal content are collected.',
1926
+ style: TextStyle(color: _textSecondary, height: 1.35),
1927
+ );
1928
+ return Material(
1929
+ color: Colors.transparent,
1930
+ child: _GlassSurface(
1931
+ borderRadius: BorderRadius.circular(22),
1932
+ blurSigma: 26,
1933
+ fillColor: _bgCard.withValues(alpha: 0.94),
1934
+ borderColor: _accent.withValues(alpha: 0.18),
1935
+ overlayGradient: LinearGradient(
1936
+ colors: <Color>[
1937
+ _accent.withValues(alpha: 0.12),
1938
+ _bgCard.withValues(alpha: 0.88),
1939
+ _bgSecondary.withValues(alpha: 0.86),
1940
+ ],
1941
+ stops: const <double>[0, 0.22, 1],
1942
+ begin: const Alignment(-1, -1),
1943
+ end: const Alignment(1, 1),
1944
+ ),
1945
+ boxShadow: <BoxShadow>[
1946
+ BoxShadow(
1947
+ color: Colors.black.withValues(alpha: 0.22),
1948
+ blurRadius: 34,
1949
+ offset: const Offset(0, 18),
1950
+ ),
1951
+ BoxShadow(
1952
+ color: _accent.withValues(alpha: 0.1),
1953
+ blurRadius: 24,
1954
+ offset: const Offset(0, 8),
1955
+ ),
1956
+ ],
1957
+ padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
1958
+ child: compact
1959
+ ? Column(
1960
+ crossAxisAlignment: CrossAxisAlignment.start,
1961
+ children: <Widget>[
1962
+ Row(
1963
+ crossAxisAlignment: CrossAxisAlignment.start,
1964
+ children: <Widget>[
1965
+ Container(
1966
+ padding: const EdgeInsets.all(10),
1967
+ decoration: BoxDecoration(
1968
+ color: _accent.withValues(alpha: 0.16),
1969
+ shape: BoxShape.circle,
1970
+ border: Border.all(
1971
+ color: _accent.withValues(alpha: 0.24),
1972
+ ),
1973
+ ),
1974
+ child: Icon(
1975
+ Icons.cookie_outlined,
1976
+ color: _accent,
1977
+ size: 22,
1978
+ ),
1979
+ ),
1980
+ const SizedBox(width: 12),
1981
+ Expanded(
1982
+ child: Column(
1983
+ crossAxisAlignment: CrossAxisAlignment.start,
1984
+ children: <Widget>[
1985
+ Text(
1986
+ 'Analytics cookies',
1987
+ style: TextStyle(
1988
+ color: _textPrimary,
1989
+ fontSize: 15,
1990
+ fontWeight: FontWeight.w700,
1991
+ ),
1992
+ ),
1993
+ const SizedBox(height: 6),
1994
+ body,
1995
+ ],
1996
+ ),
1997
+ ),
1998
+ ],
1999
+ ),
2000
+ const SizedBox(height: 14),
2001
+ Row(
2002
+ children: <Widget>[
2003
+ Expanded(
2004
+ child: OutlinedButton(
2005
+ onPressed: controller.declineAnalyticsConsent,
2006
+ child: const Text('Decline'),
2007
+ ),
2008
+ ),
2009
+ const SizedBox(width: 10),
2010
+ Expanded(
2011
+ child: FilledButton(
2012
+ onPressed: controller.acceptAnalyticsConsent,
2013
+ child: const Text('Allow analytics'),
2014
+ ),
2015
+ ),
2016
+ ],
2017
+ ),
2018
+ ],
2019
+ )
2020
+ : Row(
2021
+ crossAxisAlignment: CrossAxisAlignment.center,
2022
+ children: <Widget>[
2023
+ Container(
2024
+ padding: const EdgeInsets.all(10),
2025
+ decoration: BoxDecoration(
2026
+ color: _accent.withValues(alpha: 0.16),
2027
+ shape: BoxShape.circle,
2028
+ border: Border.all(
2029
+ color: _accent.withValues(alpha: 0.24),
2030
+ ),
2031
+ ),
2032
+ child: Icon(
2033
+ Icons.cookie_outlined,
2034
+ color: _accent,
2035
+ size: 22,
2036
+ ),
2037
+ ),
2038
+ const SizedBox(width: 14),
2039
+ Expanded(
2040
+ child: Column(
2041
+ crossAxisAlignment: CrossAxisAlignment.start,
2042
+ children: <Widget>[
2043
+ Text(
2044
+ 'Analytics cookies',
2045
+ style: TextStyle(
2046
+ color: _textPrimary,
2047
+ fontSize: 15,
2048
+ fontWeight: FontWeight.w700,
2049
+ ),
2050
+ ),
2051
+ const SizedBox(height: 6),
2052
+ body,
2053
+ ],
2054
+ ),
2055
+ ),
2056
+ const SizedBox(width: 16),
2057
+ OutlinedButton(
2058
+ onPressed: controller.declineAnalyticsConsent,
2059
+ child: const Text('Decline'),
2060
+ ),
2061
+ const SizedBox(width: 10),
2062
+ FilledButton(
2063
+ onPressed: controller.acceptAnalyticsConsent,
2064
+ child: const Text('Allow analytics'),
2065
+ ),
2066
+ ],
2067
+ ),
2068
+ ),
2069
+ );
2070
+ },
2071
+ );
2072
+ }
2073
+ }
2074
+
2075
+ class _DesktopCloseDecision {
2076
+ const _DesktopCloseDecision({
2077
+ required this.keepRunning,
2078
+ required this.rememberChoice,
2079
+ });
2080
+
2081
+ final bool keepRunning;
2082
+ final bool rememberChoice;
2083
+ }
2084
+
2085
+ class _RecordingPermissionBadge extends StatelessWidget {
2086
+ const _RecordingPermissionBadge({required this.label, required this.state});
2087
+
2088
+ final String label;
2089
+ final RecordingPermissionState state;
2090
+
2091
+ @override
2092
+ Widget build(BuildContext context) {
2093
+ final (color, icon, text) = switch (state) {
2094
+ RecordingPermissionState.granted => (
2095
+ _success,
2096
+ Icons.check_circle,
2097
+ 'Ready',
2098
+ ),
2099
+ RecordingPermissionState.denied => (
2100
+ _danger,
2101
+ Icons.lock_outline,
2102
+ 'Blocked',
2103
+ ),
2104
+ RecordingPermissionState.needsRestart => (
2105
+ _warning,
2106
+ Icons.restart_alt_rounded,
2107
+ 'Restart needed',
2108
+ ),
2109
+ RecordingPermissionState.unsupported => (
2110
+ _textSecondary,
2111
+ Icons.do_not_disturb_alt_outlined,
2112
+ 'Unsupported',
2113
+ ),
2114
+ RecordingPermissionState.unknown => (
2115
+ _warning,
2116
+ Icons.help_outline,
2117
+ 'Check access',
2118
+ ),
2119
+ };
2120
+
2121
+ return _GlassSurface(
2122
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
2123
+ borderRadius: BorderRadius.circular(16),
2124
+ blurSigma: 10,
2125
+ fillColor: color.withValues(alpha: 0.09),
2126
+ borderColor: color.withValues(alpha: 0.22),
2127
+ child: Row(
2128
+ mainAxisSize: MainAxisSize.min,
2129
+ children: <Widget>[
2130
+ Icon(icon, size: 16, color: color),
2131
+ const SizedBox(width: 8),
2132
+ Text(
2133
+ '$label · $text',
2134
+ style: TextStyle(color: color, fontWeight: FontWeight.w700),
2135
+ ),
2136
+ ],
2137
+ ),
2138
+ );
2139
+ }
2140
+ }
2141
+
2142
+ class _CompanionPermissionBadge extends StatelessWidget {
2143
+ const _CompanionPermissionBadge({required this.label, required this.state});
2144
+
2145
+ final String label;
2146
+ final String state;
2147
+
2148
+ @override
2149
+ Widget build(BuildContext context) {
2150
+ final normalized = state.trim().toLowerCase();
2151
+ final (color, icon, text) = switch (normalized) {
2152
+ 'available' => (_success, Icons.check_circle, 'Granted'),
2153
+ 'required' => (_warning, Icons.lock_outline, 'Needs access'),
2154
+ 'unsupported' => (
2155
+ _textSecondary,
2156
+ Icons.do_not_disturb_alt_outlined,
2157
+ 'Unsupported',
2158
+ ),
2159
+ _ => (_warning, Icons.help_outline, 'Unknown'),
2160
+ };
2161
+ return Container(
2162
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
2163
+ decoration: BoxDecoration(
2164
+ color: color.withValues(alpha: 0.10),
2165
+ borderRadius: BorderRadius.circular(16),
2166
+ border: Border.all(color: color.withValues(alpha: 0.20)),
2167
+ ),
2168
+ child: Row(
2169
+ mainAxisSize: MainAxisSize.min,
2170
+ children: <Widget>[
2171
+ Icon(icon, size: 16, color: color),
2172
+ const SizedBox(width: 8),
2173
+ Text(
2174
+ '$label · $text',
2175
+ style: TextStyle(color: color, fontWeight: FontWeight.w700),
2176
+ ),
2177
+ ],
2178
+ ),
2179
+ );
2180
+ }
2181
+ }
2182
+
2183
+ class _AudioLevelBar extends StatelessWidget {
2184
+ const _AudioLevelBar({
2185
+ required this.label,
2186
+ required this.valueDb,
2187
+ required this.color,
2188
+ this.compact = false,
2189
+ });
2190
+
2191
+ final String label;
2192
+ final double valueDb;
2193
+ final Color color;
2194
+ final bool compact;
2195
+
2196
+ @override
2197
+ Widget build(BuildContext context) {
2198
+ final progress = ((valueDb + 72) / 72).clamp(0.0, 1.0);
2199
+ return _GlassSurface(
2200
+ width: compact ? 168 : 240,
2201
+ padding: const EdgeInsets.all(12),
2202
+ borderRadius: BorderRadius.circular(18),
2203
+ blurSigma: 10,
2204
+ fillColor: _bgCard.withValues(alpha: 0.88),
2205
+ borderColor: _borderLight,
2206
+ child: Column(
2207
+ crossAxisAlignment: CrossAxisAlignment.start,
2208
+ children: <Widget>[
2209
+ Row(
2210
+ children: <Widget>[
2211
+ Text(
2212
+ label,
2213
+ style: TextStyle(
2214
+ color: _textSecondary,
2215
+ fontSize: compact ? 11 : 12,
2216
+ fontWeight: FontWeight.w700,
2217
+ letterSpacing: 0.3,
2218
+ ),
2219
+ ),
2220
+ const Spacer(),
2221
+ Text(
2222
+ valueDb <= -119 ? 'Silent' : '${valueDb.toStringAsFixed(0)} dB',
2223
+ style: TextStyle(
2224
+ color: _textSecondary,
2225
+ fontSize: compact ? 11 : 12,
2226
+ ),
2227
+ ),
2228
+ ],
2229
+ ),
2230
+ const SizedBox(height: 10),
2231
+ ClipRRect(
2232
+ borderRadius: BorderRadius.circular(999),
2233
+ child: TweenAnimationBuilder<double>(
2234
+ tween: Tween<double>(begin: 0, end: progress),
2235
+ duration: const Duration(milliseconds: 220),
2236
+ curve: Curves.easeOutCubic,
2237
+ builder: (context, animatedValue, _) {
2238
+ return LinearProgressIndicator(
2239
+ value: animatedValue,
2240
+ minHeight: compact ? 7 : 8,
2241
+ color: color,
2242
+ backgroundColor: _borderLight,
2243
+ );
2244
+ },
2245
+ ),
2246
+ ),
2247
+ ],
2248
+ ),
2249
+ );
2250
+ }
2251
+ }
2252
+
2253
+ class _DesktopFloatingToolbar extends StatefulWidget {
2254
+ const _DesktopFloatingToolbar({required this.controller});
2255
+
2256
+ final NeoAgentController controller;
2257
+
2258
+ @override
2259
+ State<_DesktopFloatingToolbar> createState() =>
2260
+ _DesktopFloatingToolbarState();
2261
+ }
2262
+
2263
+ class _DesktopFloatingToolbarState extends State<_DesktopFloatingToolbar> {
2264
+ Timer? _ticker;
2265
+
2266
+ @override
2267
+ void initState() {
2268
+ super.initState();
2269
+ _syncTicker();
2270
+ }
2271
+
2272
+ @override
2273
+ void didUpdateWidget(covariant _DesktopFloatingToolbar oldWidget) {
2274
+ super.didUpdateWidget(oldWidget);
2275
+ _syncTicker();
2276
+ }
2277
+
2278
+ @override
2279
+ void dispose() {
2280
+ _ticker?.cancel();
2281
+ super.dispose();
2282
+ }
2283
+
2284
+ void _syncTicker() {
2285
+ final runtime = widget.controller.recordingRuntime;
2286
+ final shouldTick =
2287
+ runtime.active &&
2288
+ runtime.startedAt != null &&
2289
+ runtime.floatingToolbarVisible;
2290
+ if (!shouldTick) {
2291
+ _ticker?.cancel();
2292
+ _ticker = null;
2293
+ return;
2294
+ }
2295
+ _ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
2296
+ if (mounted) {
2297
+ setState(() {});
2298
+ }
2299
+ });
2300
+ }
2301
+
2302
+ @override
2303
+ Widget build(BuildContext context) {
2304
+ final controller = widget.controller;
2305
+ final runtime = controller.recordingRuntime;
2306
+ if (!runtime.supportsFloatingToolbar ||
2307
+ !runtime.active ||
2308
+ !runtime.floatingToolbarVisible) {
2309
+ return const SizedBox.shrink();
2310
+ }
2311
+
2312
+ final elapsed = runtime.startedAt == null
2313
+ ? '00:00'
2314
+ : _formatDuration(
2315
+ DateTime.now().difference(runtime.startedAt!).inMilliseconds,
2316
+ );
2317
+
2318
+ return Positioned(
2319
+ top: 10,
2320
+ left: 0,
2321
+ right: 0,
2322
+ child: SafeArea(
2323
+ child: IgnorePointer(
2324
+ ignoring: false,
2325
+ child: Align(
2326
+ alignment: Alignment.topCenter,
2327
+ child: _DesktopFloatingToolbarSurface(
2328
+ controller: controller,
2329
+ elapsedLabel: elapsed,
2330
+ compactWindow: false,
2331
+ onOpenMainWindow: null,
2332
+ ),
2333
+ ),
2334
+ ),
2335
+ ),
2336
+ );
2337
+ }
2338
+ }
2339
+
2340
+ class _DetachedDesktopFloatingToolbarShell extends StatefulWidget {
2341
+ const _DetachedDesktopFloatingToolbarShell({
2342
+ required this.controller,
2343
+ required this.onOpenMainWindow,
2344
+ });
2345
+
2346
+ final NeoAgentController controller;
2347
+ final Future<void> Function() onOpenMainWindow;
2348
+
2349
+ @override
2350
+ State<_DetachedDesktopFloatingToolbarShell> createState() =>
2351
+ _DetachedDesktopFloatingToolbarShellState();
2352
+ }
2353
+
2354
+ class _DetachedDesktopFloatingToolbarShellState
2355
+ extends State<_DetachedDesktopFloatingToolbarShell> {
2356
+ Timer? _ticker;
2357
+
2358
+ @override
2359
+ void initState() {
2360
+ super.initState();
2361
+ _syncTicker();
2362
+ }
2363
+
2364
+ @override
2365
+ void didUpdateWidget(
2366
+ covariant _DetachedDesktopFloatingToolbarShell oldWidget,
2367
+ ) {
2368
+ super.didUpdateWidget(oldWidget);
2369
+ _syncTicker();
2370
+ }
2371
+
2372
+ @override
2373
+ void dispose() {
2374
+ _ticker?.cancel();
2375
+ super.dispose();
2376
+ }
2377
+
2378
+ void _syncTicker() {
2379
+ final runtime = widget.controller.recordingRuntime;
2380
+ final shouldTick =
2381
+ runtime.active &&
2382
+ runtime.startedAt != null &&
2383
+ runtime.floatingToolbarVisible;
2384
+ if (!shouldTick) {
2385
+ _ticker?.cancel();
2386
+ _ticker = null;
2387
+ return;
2388
+ }
2389
+ _ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
2390
+ if (mounted) {
2391
+ setState(() {});
2392
+ }
2393
+ });
2394
+ }
2395
+
2396
+ @override
2397
+ Widget build(BuildContext context) {
2398
+ final runtime = widget.controller.recordingRuntime;
2399
+ if (!runtime.active || !runtime.floatingToolbarVisible) {
2400
+ return const SizedBox.shrink();
2401
+ }
2402
+
2403
+ final elapsed = runtime.startedAt == null
2404
+ ? '00:00'
2405
+ : _formatDuration(
2406
+ DateTime.now().difference(runtime.startedAt!).inMilliseconds,
2407
+ );
2408
+
2409
+ return DecoratedBox(
2410
+ decoration: const BoxDecoration(color: Colors.transparent),
2411
+ child: Scaffold(
2412
+ backgroundColor: Colors.transparent,
2413
+ body: SafeArea(
2414
+ child: Center(
2415
+ child: Padding(
2416
+ padding: const EdgeInsets.all(10),
2417
+ child: _DesktopFloatingToolbarSurface(
2418
+ controller: widget.controller,
2419
+ elapsedLabel: elapsed,
2420
+ compactWindow: true,
2421
+ onOpenMainWindow: widget.onOpenMainWindow,
2422
+ ),
2423
+ ),
2424
+ ),
2425
+ ),
2426
+ ),
2427
+ );
2428
+ }
2429
+ }
2430
+
2431
+ bool _desktopAssistantUsesToggleControls() {
2432
+ if (kIsWeb) {
2433
+ return false;
2434
+ }
2435
+ switch (defaultTargetPlatform) {
2436
+ case TargetPlatform.macOS:
2437
+ case TargetPlatform.windows:
2438
+ case TargetPlatform.linux:
2439
+ return true;
2440
+ case TargetPlatform.android:
2441
+ case TargetPlatform.iOS:
2442
+ case TargetPlatform.fuchsia:
2443
+ return false;
2444
+ }
2445
+ }
2446
+
2447
+ String _desktopAssistantPrimaryLabel(bool isCapturing) {
2448
+ if (_desktopAssistantUsesToggleControls()) {
2449
+ return isCapturing ? 'Stop and send' : 'Start talking';
2450
+ }
2451
+ return isCapturing ? 'Release to send' : 'Hold to talk';
2452
+ }
2453
+
2454
+ String _desktopAssistantPrimaryCaption(bool isCapturing) {
2455
+ if (_desktopAssistantUsesToggleControls()) {
2456
+ return isCapturing
2457
+ ? 'Commit the active live capture'
2458
+ : 'Click once to begin capturing';
2459
+ }
2460
+ return isCapturing
2461
+ ? 'Stop capture and submit'
2462
+ : 'Press and hold for quick capture';
2463
+ }
2464
+
2465
+ String _desktopAssistantIdleHint() {
2466
+ return _desktopAssistantUsesToggleControls()
2467
+ ? 'Click the mic to start speaking'
2468
+ : 'Hold Ctrl+Shift+Space to talk';
2469
+ }
2470
+
2471
+ String _desktopAssistantScreenContextHint(bool enabled) {
2472
+ return enabled ? 'Current screen will be attached' : 'Audio only';
2473
+ }
2474
+
2475
+ class _DesktopAssistantControlState {
2476
+ const _DesktopAssistantControlState({
2477
+ required this.isCapturing,
2478
+ required this.isBusy,
2479
+ required this.useToggleCapture,
2480
+ required this.statusLabel,
2481
+ required this.statusColor,
2482
+ required this.transcriptPreview,
2483
+ required this.primaryLabel,
2484
+ required this.primaryCaption,
2485
+ required this.primaryIcon,
2486
+ required this.primaryColor,
2487
+ required this.idleHint,
2488
+ required this.screenContextHint,
2489
+ required this.sourceSummary,
2490
+ });
2491
+
2492
+ factory _DesktopAssistantControlState.fromController(
2493
+ NeoAgentController controller, {
2494
+ required bool blockedHintVisible,
2495
+ }) {
2496
+ final liveState = controller.voiceAssistantLiveState;
2497
+ final isCapturing = controller.isLiveVoiceCaptureEngaged;
2498
+ final includeScreenContext = controller.voiceAssistantIncludeScreenContext;
2499
+ final useToggleCapture = _desktopAssistantUsesToggleControls();
2500
+ final transcriptPreview = liveState.partialTranscript.trim().isEmpty
2501
+ ? liveState.finalTranscript.trim()
2502
+ : liveState.partialTranscript.trim();
2503
+ return _DesktopAssistantControlState(
2504
+ isCapturing: isCapturing,
2505
+ isBusy: liveState.isBusy,
2506
+ useToggleCapture: useToggleCapture,
2507
+ statusLabel: blockedHintVisible
2508
+ ? 'Assistant unavailable while recording'
2509
+ : (isCapturing
2510
+ ? _desktopAssistantPrimaryLabel(true)
2511
+ : _desktopAssistantStatusLabel(liveState.state)),
2512
+ statusColor: blockedHintVisible
2513
+ ? _warning
2514
+ : (isCapturing ? _success : _accent),
2515
+ transcriptPreview: transcriptPreview,
2516
+ primaryLabel: _desktopAssistantPrimaryLabel(isCapturing),
2517
+ primaryCaption: _desktopAssistantPrimaryCaption(isCapturing),
2518
+ primaryIcon: isCapturing ? Icons.stop_rounded : Icons.mic,
2519
+ primaryColor: isCapturing ? _warning : _success,
2520
+ idleHint: _desktopAssistantIdleHint(),
2521
+ screenContextHint: _desktopAssistantScreenContextHint(
2522
+ includeScreenContext,
2523
+ ),
2524
+ sourceSummary: includeScreenContext ? 'Mic + screen' : 'Direct mic',
2525
+ );
2526
+ }
2527
+
2528
+ final bool isCapturing;
2529
+ final bool isBusy;
2530
+ final bool useToggleCapture;
2531
+ final String statusLabel;
2532
+ final Color statusColor;
2533
+ final String transcriptPreview;
2534
+ final String primaryLabel;
2535
+ final String primaryCaption;
2536
+ final IconData primaryIcon;
2537
+ final Color primaryColor;
2538
+ final String idleHint;
2539
+ final String screenContextHint;
2540
+ final String sourceSummary;
2541
+ }
2542
+
2543
+ class _VoiceAssistantScreenContextButton extends StatelessWidget {
2544
+ const _VoiceAssistantScreenContextButton({
2545
+ required this.controller,
2546
+ required this.compact,
2547
+ });
2548
+
2549
+ final NeoAgentController controller;
2550
+ final bool compact;
2551
+
2552
+ @override
2553
+ Widget build(BuildContext context) {
2554
+ final enabled = controller.voiceAssistantIncludeScreenContext;
2555
+ final onPressed = controller.canCaptureVoiceAssistantScreenContext
2556
+ ? () {
2557
+ unawaited(controller.toggleVoiceAssistantScreenContext());
2558
+ }
2559
+ : null;
2560
+
2561
+ if (compact) {
2562
+ return IconButton(
2563
+ tooltip: enabled
2564
+ ? 'Stop including the current screen'
2565
+ : 'Include the current screen',
2566
+ onPressed: onPressed,
2567
+ style: IconButton.styleFrom(
2568
+ visualDensity: VisualDensity.compact,
2569
+ padding: const EdgeInsets.all(8),
2570
+ minimumSize: const Size(30, 30),
2571
+ backgroundColor: enabled
2572
+ ? _accent.withValues(alpha: 0.14)
2573
+ : _bgSecondary.withValues(alpha: 0.9),
2574
+ foregroundColor: enabled ? _accent : _textSecondary,
2575
+ ),
2576
+ icon: Icon(
2577
+ enabled
2578
+ ? Icons.desktop_windows_rounded
2579
+ : Icons.desktop_windows_outlined,
2580
+ size: 15,
2581
+ ),
2582
+ );
2583
+ }
2584
+
2585
+ return _VoiceAssistantActionButton(
2586
+ icon: enabled
2587
+ ? Icons.desktop_windows_rounded
2588
+ : Icons.desktop_windows_outlined,
2589
+ label: enabled ? 'Screen on' : 'Screen off',
2590
+ onTap: onPressed,
2591
+ );
2592
+ }
2593
+ }
2594
+
2595
+ class _DesktopAssistantPopupShell extends StatelessWidget {
2596
+ const _DesktopAssistantPopupShell({
2597
+ required this.controller,
2598
+ required this.blockedHintVisible,
2599
+ required this.onPrimaryAction,
2600
+ required this.onCancel,
2601
+ });
2602
+
2603
+ final NeoAgentController controller;
2604
+ final bool blockedHintVisible;
2605
+ final Future<void> Function() onPrimaryAction;
2606
+ final Future<void> Function() onCancel;
2607
+
2608
+ @override
2609
+ Widget build(BuildContext context) {
2610
+ final assistantUi = _DesktopAssistantControlState.fromController(
2611
+ controller,
2612
+ blockedHintVisible: blockedHintVisible,
2613
+ );
2614
+
2615
+ return DecoratedBox(
2616
+ decoration: const BoxDecoration(color: Colors.transparent),
2617
+ child: Scaffold(
2618
+ backgroundColor: Colors.transparent,
2619
+ body: SafeArea(
2620
+ child: Align(
2621
+ alignment: Alignment.bottomCenter,
2622
+ child: Padding(
2623
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 18),
2624
+ child: Material(
2625
+ color: Colors.transparent,
2626
+ child: Container(
2627
+ constraints: const BoxConstraints(maxWidth: 430),
2628
+ padding: const EdgeInsets.symmetric(
2629
+ horizontal: 12,
2630
+ vertical: 10,
2631
+ ),
2632
+ decoration: BoxDecoration(
2633
+ gradient: LinearGradient(
2634
+ colors: <Color>[
2635
+ _bgCard.withValues(alpha: 0.99),
2636
+ _bgSecondary.withValues(alpha: 0.97),
2637
+ ],
2638
+ ),
2639
+ borderRadius: BorderRadius.circular(999),
2640
+ border: Border.all(
2641
+ color: _borderLight.withValues(alpha: 0.9),
2642
+ ),
2643
+ boxShadow: <BoxShadow>[
2644
+ BoxShadow(
2645
+ color: Colors.black.withValues(alpha: 0.2),
2646
+ blurRadius: 18,
2647
+ offset: const Offset(0, 8),
2648
+ ),
2649
+ ],
2650
+ ),
2651
+ child: Row(
2652
+ mainAxisSize: MainAxisSize.max,
2653
+ children: <Widget>[
2654
+ _DesktopAssistantPulseDots(
2655
+ color: assistantUi.statusColor,
2656
+ active: assistantUi.isCapturing || assistantUi.isBusy,
2657
+ ),
2658
+ const SizedBox(width: 12),
2659
+ _DesktopAssistantWaveform(
2660
+ color: assistantUi.statusColor,
2661
+ active: assistantUi.isCapturing,
2662
+ busy: assistantUi.isBusy,
2663
+ ),
2664
+ const SizedBox(width: 12),
2665
+ Expanded(
2666
+ child: Column(
2667
+ mainAxisSize: MainAxisSize.min,
2668
+ crossAxisAlignment: CrossAxisAlignment.start,
2669
+ children: <Widget>[
2670
+ Text(
2671
+ assistantUi.statusLabel,
2672
+ maxLines: 1,
2673
+ overflow: TextOverflow.ellipsis,
2674
+ style: TextStyle(
2675
+ color: _textPrimary,
2676
+ fontWeight: FontWeight.w700,
2677
+ fontSize: 12.5,
2678
+ ),
2679
+ ),
2680
+ if (!blockedHintVisible)
2681
+ Text(
2682
+ assistantUi.transcriptPreview.isEmpty
2683
+ ? '${assistantUi.idleHint} • ${assistantUi.screenContextHint}'
2684
+ : assistantUi.transcriptPreview,
2685
+ maxLines: 1,
2686
+ overflow: TextOverflow.ellipsis,
2687
+ style: TextStyle(
2688
+ color: _textMuted,
2689
+ fontSize: 11.5,
2690
+ height: 1.35,
2691
+ ),
2692
+ ),
2693
+ ],
2694
+ ),
2695
+ ),
2696
+ const SizedBox(width: 8),
2697
+ _VoiceAssistantScreenContextButton(
2698
+ controller: controller,
2699
+ compact: true,
2700
+ ),
2701
+ const SizedBox(width: 4),
2702
+ FilledButton.icon(
2703
+ onPressed: blockedHintVisible
2704
+ ? null
2705
+ : () {
2706
+ unawaited(onPrimaryAction());
2707
+ },
2708
+ style: FilledButton.styleFrom(
2709
+ visualDensity: VisualDensity.compact,
2710
+ padding: const EdgeInsets.symmetric(
2711
+ horizontal: 12,
2712
+ vertical: 10,
2713
+ ),
2714
+ minimumSize: const Size(0, 38),
2715
+ backgroundColor: assistantUi.statusColor,
2716
+ foregroundColor: Colors.white,
2717
+ ),
2718
+ icon: Icon(
2719
+ assistantUi.isCapturing
2720
+ ? Icons.stop_rounded
2721
+ : Icons.mic_rounded,
2722
+ size: 16,
2723
+ ),
2724
+ label: Text(
2725
+ assistantUi.isCapturing ? 'Send' : 'Talk',
2726
+ style: const TextStyle(fontWeight: FontWeight.w700),
2727
+ ),
2728
+ ),
2729
+ IconButton(
2730
+ tooltip: 'Cancel',
2731
+ onPressed: () {
2732
+ unawaited(onCancel());
2733
+ },
2734
+ style: IconButton.styleFrom(
2735
+ visualDensity: VisualDensity.compact,
2736
+ padding: const EdgeInsets.all(8),
2737
+ minimumSize: const Size(30, 30),
2738
+ backgroundColor: _bgSecondary.withValues(alpha: 0.9),
2739
+ foregroundColor: _textSecondary,
2740
+ ),
2741
+ icon: const Icon(Icons.close_rounded, size: 14),
2742
+ ),
2743
+ ],
2744
+ ),
2745
+ ),
2746
+ ),
2747
+ ),
2748
+ ),
2749
+ ),
2750
+ ),
2751
+ );
2752
+ }
2753
+ }
2754
+
2755
+ String _desktopAssistantStatusLabel(String state) {
2756
+ switch (state.trim().toLowerCase()) {
2757
+ case 'transcribing':
2758
+ return 'Transcribing';
2759
+ case 'thinking':
2760
+ return 'Thinking';
2761
+ case 'speaking':
2762
+ return 'Speaking';
2763
+ case 'listening':
2764
+ return 'Listening';
2765
+ case 'idle':
2766
+ default:
2767
+ return 'Ready';
2768
+ }
2769
+ }
2770
+
2771
+ class _DesktopAssistantPulseDots extends StatelessWidget {
2772
+ const _DesktopAssistantPulseDots({required this.color, required this.active});
2773
+
2774
+ final Color color;
2775
+ final bool active;
2776
+
2777
+ @override
2778
+ Widget build(BuildContext context) {
2779
+ return SizedBox(
2780
+ width: 26,
2781
+ child: Wrap(
2782
+ spacing: 3,
2783
+ runSpacing: 3,
2784
+ children: List<Widget>.generate(6, (index) {
2785
+ final opacity = active ? 0.35 + (index % 3) * 0.2 : 0.28;
2786
+ return Container(
2787
+ width: 5,
2788
+ height: 5,
2789
+ decoration: BoxDecoration(
2790
+ color: color.withValues(alpha: opacity),
2791
+ shape: BoxShape.circle,
2792
+ ),
2793
+ );
2794
+ }),
2795
+ ),
2796
+ );
2797
+ }
2798
+ }
2799
+
2800
+ class _DesktopAssistantWaveform extends StatefulWidget {
2801
+ const _DesktopAssistantWaveform({
2802
+ required this.color,
2803
+ required this.active,
2804
+ required this.busy,
2805
+ });
2806
+
2807
+ final Color color;
2808
+ final bool active;
2809
+ final bool busy;
2810
+
2811
+ @override
2812
+ State<_DesktopAssistantWaveform> createState() =>
2813
+ _DesktopAssistantWaveformState();
2814
+ }
2815
+
2816
+ class _DesktopAssistantWaveformState extends State<_DesktopAssistantWaveform>
2817
+ with SingleTickerProviderStateMixin {
2818
+ late final AnimationController _controller;
2819
+
2820
+ @override
2821
+ void initState() {
2822
+ super.initState();
2823
+ _controller = AnimationController(
2824
+ vsync: this,
2825
+ duration: const Duration(milliseconds: 980),
2826
+ );
2827
+ _syncAnimation();
2828
+ }
2829
+
2830
+ @override
2831
+ void didUpdateWidget(covariant _DesktopAssistantWaveform oldWidget) {
2832
+ super.didUpdateWidget(oldWidget);
2833
+ _syncAnimation();
2834
+ }
2835
+
2836
+ @override
2837
+ void dispose() {
2838
+ _controller.dispose();
2839
+ super.dispose();
2840
+ }
2841
+
2842
+ void _syncAnimation() {
2843
+ if (widget.active || widget.busy) {
2844
+ if (!_controller.isAnimating) {
2845
+ _controller.repeat();
2846
+ }
2847
+ return;
2848
+ }
2849
+ _controller.stop();
2850
+ _controller.value = 0;
2851
+ }
2852
+
2853
+ @override
2854
+ Widget build(BuildContext context) {
2855
+ const barCount = 18;
2856
+ return SizedBox(
2857
+ width: 116,
2858
+ height: 18,
2859
+ child: AnimatedBuilder(
2860
+ animation: _controller,
2861
+ builder: (context, child) {
2862
+ return Row(
2863
+ children: List<Widget>.generate(barCount, (index) {
2864
+ final phase = _controller.value * 2 * math.pi;
2865
+ final wave = math.sin(phase + index * 0.55);
2866
+ final minHeight = widget.busy ? 3.0 : 2.0;
2867
+ final maxHeight = widget.active
2868
+ ? 12.0
2869
+ : (widget.busy ? 7.0 : 3.0);
2870
+ final normalized = widget.active || widget.busy
2871
+ ? (wave + 1) / 2
2872
+ : 0.2;
2873
+ final height = minHeight + (maxHeight - minHeight) * normalized;
2874
+ return Padding(
2875
+ padding: const EdgeInsets.only(right: 2),
2876
+ child: Align(
2877
+ alignment: Alignment.bottomCenter,
2878
+ child: Container(
2879
+ width: 3,
2880
+ height: height,
2881
+ decoration: BoxDecoration(
2882
+ color: widget.color.withValues(
2883
+ alpha: widget.active ? 0.9 : (widget.busy ? 0.65 : 0.4),
2884
+ ),
2885
+ borderRadius: BorderRadius.circular(99),
2886
+ ),
2887
+ ),
2888
+ ),
2889
+ );
2890
+ }),
2891
+ );
2892
+ },
2893
+ ),
2894
+ );
2895
+ }
2896
+ }
2897
+
2898
+ class _DesktopFloatingToolbarSurface extends StatelessWidget {
2899
+ const _DesktopFloatingToolbarSurface({
2900
+ required this.controller,
2901
+ required this.elapsedLabel,
2902
+ required this.compactWindow,
2903
+ required this.onOpenMainWindow,
2904
+ });
2905
+
2906
+ final NeoAgentController controller;
2907
+ final String elapsedLabel;
2908
+ final bool compactWindow;
2909
+ final Future<void> Function()? onOpenMainWindow;
2910
+
2911
+ @override
2912
+ Widget build(BuildContext context) {
2913
+ final runtime = controller.recordingRuntime;
2914
+ return Material(
2915
+ color: Colors.transparent,
2916
+ child: Container(
2917
+ constraints: BoxConstraints(
2918
+ maxWidth: compactWindow ? double.infinity : 680,
2919
+ ),
2920
+ margin: compactWindow
2921
+ ? EdgeInsets.zero
2922
+ : const EdgeInsets.symmetric(horizontal: 16),
2923
+ padding: EdgeInsets.symmetric(
2924
+ horizontal: compactWindow ? 10 : 14,
2925
+ vertical: compactWindow ? 8 : 12,
2926
+ ),
2927
+ decoration: BoxDecoration(
2928
+ gradient: LinearGradient(
2929
+ colors: <Color>[
2930
+ _bgCard.withValues(alpha: 0.98),
2931
+ _bgCard.withValues(alpha: 0.92),
2932
+ ],
2933
+ ),
2934
+ borderRadius: BorderRadius.circular(compactWindow ? 22 : 24),
2935
+ border: Border.all(color: _borderLight),
2936
+ boxShadow: <BoxShadow>[
2937
+ BoxShadow(
2938
+ color: Colors.black.withValues(alpha: 0.24),
2939
+ blurRadius: 24,
2940
+ offset: const Offset(0, 12),
2941
+ ),
2942
+ ],
2943
+ ),
2944
+ child: Wrap(
2945
+ spacing: 10,
2946
+ runSpacing: 10,
2947
+ crossAxisAlignment: WrapCrossAlignment.center,
2948
+ children: <Widget>[
2949
+ if (compactWindow)
2950
+ const _BrandLockup(
2951
+ logoSize: 34,
2952
+ titleFontSize: 16,
2953
+ direction: Axis.horizontal,
2954
+ spacing: 10,
2955
+ alignment: CrossAxisAlignment.start,
2956
+ ),
2957
+ if (compactWindow)
2958
+ DragToMoveArea(
2959
+ child: Container(
2960
+ padding: const EdgeInsets.symmetric(
2961
+ horizontal: 8,
2962
+ vertical: 6,
2963
+ ),
2964
+ decoration: BoxDecoration(
2965
+ color: _bgSecondary.withValues(alpha: 0.78),
2966
+ borderRadius: BorderRadius.circular(12),
2967
+ border: Border.all(color: _borderLight),
2968
+ ),
2969
+ child: Icon(
2970
+ Icons.drag_indicator_rounded,
2971
+ size: 14,
2972
+ color: _textMuted,
2973
+ ),
2974
+ ),
2975
+ ),
2976
+ Container(
2977
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
2978
+ decoration: BoxDecoration(
2979
+ color: (runtime.paused ? _warning : _danger).withValues(
2980
+ alpha: 0.10,
2981
+ ),
2982
+ borderRadius: BorderRadius.circular(16),
2983
+ border: Border.all(
2984
+ color: (runtime.paused ? _warning : _danger).withValues(
2985
+ alpha: 0.20,
2986
+ ),
2987
+ ),
2988
+ ),
2989
+ child: Row(
2990
+ mainAxisSize: MainAxisSize.min,
2991
+ children: <Widget>[
2992
+ Icon(
2993
+ runtime.paused
2994
+ ? Icons.pause_circle_outline
2995
+ : Icons.fiber_manual_record_rounded,
2996
+ color: runtime.paused ? _warning : _danger,
2997
+ size: 18,
2998
+ ),
2999
+ const SizedBox(width: 8),
3000
+ Text(
3001
+ runtime.paused ? 'Paused' : 'Recording',
3002
+ style: TextStyle(
3003
+ color: runtime.paused ? _warning : _danger,
3004
+ fontSize: 12,
3005
+ fontWeight: FontWeight.w800,
3006
+ ),
3007
+ ),
3008
+ const SizedBox(width: 10),
3009
+ Text(
3010
+ elapsedLabel,
3011
+ style: TextStyle(
3012
+ color: _textPrimary,
3013
+ fontSize: 12,
3014
+ fontWeight: FontWeight.w700,
3015
+ ),
3016
+ ),
3017
+ ],
3018
+ ),
3019
+ ),
3020
+ _AudioLevelBar(
3021
+ label: 'MIC',
3022
+ valueDb: runtime.microphoneLevelDb,
3023
+ color: _accent,
3024
+ compact: true,
3025
+ ),
3026
+ _AudioLevelBar(
3027
+ label: 'SYSTEM',
3028
+ valueDb: runtime.systemAudioLevelDb,
3029
+ color: _accentAlt,
3030
+ compact: true,
3031
+ ),
3032
+ if (compactWindow && onOpenMainWindow != null)
3033
+ IconButton(
3034
+ tooltip: 'Open NeoAgent',
3035
+ onPressed: onOpenMainWindow,
3036
+ style: IconButton.styleFrom(
3037
+ backgroundColor: _bgSecondary,
3038
+ foregroundColor: _textPrimary,
3039
+ ),
3040
+ icon: const Icon(Icons.open_in_full_rounded),
3041
+ ),
3042
+ IconButton(
3043
+ tooltip: runtime.paused ? 'Resume recording' : 'Pause recording',
3044
+ onPressed: runtime.paused
3045
+ ? controller.resumeDesktopRecording
3046
+ : controller.pauseDesktopRecording,
3047
+ style: IconButton.styleFrom(
3048
+ backgroundColor: _bgSecondary,
3049
+ foregroundColor: _textPrimary,
3050
+ ),
3051
+ icon: Icon(
3052
+ runtime.paused ? Icons.play_arrow_rounded : Icons.pause_rounded,
3053
+ ),
3054
+ ),
3055
+ IconButton(
3056
+ tooltip: 'Stop recording',
3057
+ onPressed: controller.isStoppingRecording
3058
+ ? null
3059
+ : controller.stopRecording,
3060
+ style: IconButton.styleFrom(
3061
+ backgroundColor: _danger.withValues(alpha: 0.12),
3062
+ foregroundColor: _danger,
3063
+ ),
3064
+ icon: const Icon(Icons.stop_rounded),
3065
+ ),
3066
+ IconButton(
3067
+ tooltip: 'Hide floating bar',
3068
+ onPressed: controller.hideDesktopFloatingToolbar,
3069
+ style: IconButton.styleFrom(
3070
+ backgroundColor: _bgSecondary,
3071
+ foregroundColor: _textSecondary,
3072
+ ),
3073
+ icon: const Icon(Icons.close_rounded),
3074
+ ),
3075
+ ],
3076
+ ),
3077
+ ),
3078
+ );
3079
+ }
3080
+ }
3081
+
3082
+ String _ensureModelValue(
3083
+ String value,
3084
+ List<ModelMeta> models, {
3085
+ required bool allowAuto,
3086
+ }) {
3087
+ if (allowAuto && value == 'auto') {
3088
+ return 'auto';
3089
+ }
3090
+ for (final model in models) {
3091
+ if (model.id == value) {
3092
+ return value;
3093
+ }
3094
+ }
3095
+ if (allowAuto) {
3096
+ return 'auto';
3097
+ }
3098
+ return models.isNotEmpty ? models.first.id : value;
3099
+ }
3100
+
3101
+ String _firstAvailableModelId(List<ModelMeta> models) {
3102
+ for (final model in models) {
3103
+ if (model.available) {
3104
+ return model.id;
3105
+ }
3106
+ }
3107
+ return models.isNotEmpty ? models.first.id : 'auto';
3108
+ }
3109
+
3110
+ String _modelLabelForValue(String value, List<ModelMeta> models) {
3111
+ if (value == 'auto' || value.trim().isEmpty) {
3112
+ return 'Auto';
3113
+ }
3114
+ for (final model in models) {
3115
+ if (model.id == value) {
3116
+ return model.label;
3117
+ }
3118
+ }
3119
+ return value;
3120
+ }
3121
+
3122
+ String _friendlyBaseUrlLabel(String value) {
3123
+ final uri = Uri.tryParse(value);
3124
+ if (uri == null || uri.host.trim().isEmpty) {
3125
+ return value;
3126
+ }
3127
+ final port = uri.hasPort ? ':${uri.port}' : '';
3128
+ return '${uri.host}$port';
3129
+ }
3130
+
3131
+ String? _androidRuntimeVersionLabel(Map<String, dynamic> runtime) {
3132
+ final apiLevel = _asInt(runtime['apiLevel']);
3133
+ final systemImage = runtime['systemImage']?.toString().trim() ?? '';
3134
+ if (apiLevel <= 0 && systemImage.isEmpty) {
3135
+ return null;
3136
+ }
3137
+
3138
+ if (apiLevel > 0) {
3139
+ return 'Android $apiLevel';
3140
+ }
3141
+ return systemImage;
3142
+ }
3143
+
3144
+ Map<String, dynamic> _jsonMap(dynamic value) {
3145
+ if (value is Map<String, dynamic>) {
3146
+ return value;
3147
+ }
3148
+ if (value is Map) {
3149
+ return Map<String, dynamic>.from(value);
3150
+ }
3151
+ return const <String, dynamic>{};
3152
+ }
3153
+
3154
+ List<dynamic> _jsonList(
3155
+ dynamic value, {
3156
+ List<String> nestedKeys = const <String>[
3157
+ 'items',
3158
+ 'data',
3159
+ 'results',
3160
+ 'rows',
3161
+ 'values',
3162
+ 'list',
3163
+ ],
3164
+ bool fallbackToMapValues = false,
3165
+ }) {
3166
+ if (value is List) {
3167
+ return value;
3168
+ }
3169
+ if (value is Map) {
3170
+ for (final key in nestedKeys) {
3171
+ final nested = value[key];
3172
+ if (nested is List) {
3173
+ return nested;
3174
+ }
3175
+ }
3176
+ if (fallbackToMapValues) {
3177
+ return value.values.toList(growable: false);
3178
+ }
3179
+ }
3180
+ return const <dynamic>[];
3181
+ }
3182
+
3183
+ List<Map<String, dynamic>> _jsonMapList(
3184
+ dynamic value, {
3185
+ List<String> nestedKeys = const <String>[
3186
+ 'items',
3187
+ 'data',
3188
+ 'results',
3189
+ 'rows',
3190
+ 'values',
3191
+ 'list',
3192
+ ],
3193
+ bool fallbackToMapValues = false,
3194
+ }) {
3195
+ return _jsonList(
3196
+ value,
3197
+ nestedKeys: nestedKeys,
3198
+ fallbackToMapValues: fallbackToMapValues,
3199
+ ).whereType<Map>().map((item) => Map<String, dynamic>.from(item)).toList();
3200
+ }
3201
+
3202
+ List<String> _jsonStringList(
3203
+ dynamic value, {
3204
+ List<String> nestedKeys = const <String>[
3205
+ 'items',
3206
+ 'data',
3207
+ 'results',
3208
+ 'rows',
3209
+ 'values',
3210
+ 'list',
3211
+ ],
3212
+ bool fallbackToMapValues = false,
3213
+ }) {
3214
+ return _jsonList(
3215
+ value,
3216
+ nestedKeys: nestedKeys,
3217
+ fallbackToMapValues: fallbackToMapValues,
3218
+ )
3219
+ .map((item) => item?.toString() ?? '')
3220
+ .where((item) => item.isNotEmpty)
3221
+ .toList();
3222
+ }
3223
+
3224
+ String _normalizeSuggestedWhitelistEntry(String platform, String entry) {
3225
+ final trimmed = entry.trim();
3226
+ if (trimmed.isEmpty) {
3227
+ return '';
3228
+ }
3229
+ switch (platform) {
3230
+ case 'whatsapp':
3231
+ return trimmed.replaceAll(RegExp(r'[^0-9]'), '');
3232
+ case 'telnyx':
3233
+ return trimmed.replaceAll(RegExp(r'[^0-9+]'), '');
3234
+ case 'discord':
3235
+ case 'telegram':
3236
+ return trimmed.replaceAll(
3237
+ RegExp(r'[^0-9a-z:_-]', caseSensitive: false),
3238
+ '',
3239
+ );
3240
+ default:
3241
+ return trimmed;
3242
+ }
3243
+ }
3244
+
3245
+ int _asInt(dynamic value) {
3246
+ if (value is int) {
3247
+ return value;
3248
+ }
3249
+ if (value is double) {
3250
+ return value.round();
3251
+ }
3252
+ return int.tryParse(value?.toString() ?? '') ?? 0;
3253
+ }
3254
+
3255
+ DateTime _parseTimestamp(String? raw) {
3256
+ if (raw == null || raw.isEmpty) {
3257
+ return DateTime.now();
3258
+ }
3259
+ final normalized = raw.contains('T') ? raw : '${raw.replaceFirst(' ', 'T')}Z';
3260
+ return DateTime.tryParse(normalized)?.toLocal() ?? DateTime.now();
3261
+ }
3262
+
3263
+ DateTime? _parseOptionalTimestamp(String? raw) {
3264
+ if (raw == null || raw.isEmpty) {
3265
+ return null;
3266
+ }
3267
+ return _parseTimestamp(raw);
3268
+ }
3269
+
3270
+ String _formatTimestamp(DateTime value) {
3271
+ final hour = value.hour.toString().padLeft(2, '0');
3272
+ final minute = value.minute.toString().padLeft(2, '0');
3273
+ final month = value.month.toString().padLeft(2, '0');
3274
+ final day = value.day.toString().padLeft(2, '0');
3275
+ return '$month/$day $hour:$minute';
3276
+ }
3277
+
3278
+ String _formatTimeOnly(DateTime value) {
3279
+ final hour = value.hour.toString().padLeft(2, '0');
3280
+ final minute = value.minute.toString().padLeft(2, '0');
3281
+ final second = value.second.toString().padLeft(2, '0');
3282
+ return '$hour:$minute:$second';
3283
+ }
3284
+
3285
+ String _formatDuration(int milliseconds) {
3286
+ final totalSeconds = math.max(0, milliseconds ~/ 1000);
3287
+ final hours = totalSeconds ~/ 3600;
3288
+ final minutes = (totalSeconds % 3600) ~/ 60;
3289
+ final seconds = totalSeconds % 60;
3290
+ if (hours > 0) {
3291
+ return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
3292
+ }
3293
+ return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
3294
+ }
3295
+
3296
+ String _formatElapsed(Duration value) {
3297
+ final totalSeconds = math.max(0, value.inSeconds);
3298
+ final hours = totalSeconds ~/ 3600;
3299
+ final minutes = (totalSeconds % 3600) ~/ 60;
3300
+ final seconds = totalSeconds % 60;
3301
+ if (hours > 0) {
3302
+ return '${hours}h ${minutes}m';
3303
+ }
3304
+ if (minutes > 0) {
3305
+ return '${minutes}m ${seconds}s';
3306
+ }
3307
+ return '${seconds}s';
3308
+ }
3309
+
3310
+ String _formatNumber(int value) {
3311
+ final chars = value.abs().toString().split('').reversed.toList();
3312
+ final buffer = StringBuffer();
3313
+ for (var i = 0; i < chars.length; i++) {
3314
+ if (i > 0 && i % 3 == 0) {
3315
+ buffer.write('.');
3316
+ }
3317
+ buffer.write(chars[i]);
3318
+ }
3319
+ final formatted = buffer.toString().split('').reversed.join();
3320
+ return value < 0 ? '-$formatted' : formatted;
3321
+ }
3322
+
3323
+ String _summarizeToolArgs(dynamic raw) {
3324
+ if (raw is Map && raw.isNotEmpty) {
3325
+ final first = raw.entries.first;
3326
+ return '${first.key}: ${first.value}'.trim();
3327
+ }
3328
+ return '';
3329
+ }
3330
+
3331
+ String _summarizeToolResult(dynamic raw) {
3332
+ if (raw == null) {
3333
+ return '';
3334
+ }
3335
+ if (raw is Map) {
3336
+ if (raw['timedOut'] == true) {
3337
+ final durationMs = _asInt(raw['durationMs']);
3338
+ final durationText = durationMs > 0
3339
+ ? ' after ${_formatDuration(durationMs)}'
3340
+ : '';
3341
+ return 'Timed out$durationText';
3342
+ }
3343
+ if (raw['killed'] == true) {
3344
+ return 'Stopped before completion';
3345
+ }
3346
+ if (raw['error'] != null) {
3347
+ return raw['error'].toString();
3348
+ }
3349
+ if (raw['status'] != null && raw['status'].toString() == 'stopped') {
3350
+ return 'Stopped';
3351
+ }
3352
+ if (raw['message'] != null) {
3353
+ return raw['message'].toString();
3354
+ }
3355
+ if (raw['content'] != null) {
3356
+ return raw['content'].toString();
3357
+ }
3358
+ return raw.entries
3359
+ .take(2)
3360
+ .map((entry) => '${entry.key}: ${entry.value}')
3361
+ .join(' • ');
3362
+ }
3363
+ final text = raw.toString();
3364
+ return text.length > 140 ? '${text.substring(0, 140)}…' : text;
3365
+ }
3366
+
3367
+ String _titleCase(String value) {
3368
+ final normalized = value.trim();
3369
+ if (normalized.isEmpty) {
3370
+ return '';
3371
+ }
3372
+ return normalized
3373
+ .split(RegExp(r'\s+'))
3374
+ .map((part) {
3375
+ if (part.isEmpty) {
3376
+ return part;
3377
+ }
3378
+ return '${part[0].toUpperCase()}${part.substring(1)}';
3379
+ })
3380
+ .join(' ');
3381
+ }
3382
+
3383
+ String _truncateRunText(String value, {int maxLength = 1400}) {
3384
+ final trimmed = value.trim();
3385
+ if (trimmed.length <= maxLength) {
3386
+ return trimmed;
3387
+ }
3388
+ return '${trimmed.substring(0, maxLength)}\n\n…truncated…';
3389
+ }
3390
+
3391
+ extension on String {
3392
+ String ifEmpty(String fallback) => trim().isEmpty ? fallback : this;
3393
+ }