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

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