nastechai-desktop 18.1.0

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 (546) hide show
  1. package/.prettierrc +11 -0
  2. package/DESIGN.md +167 -0
  3. package/README.md +141 -0
  4. package/assets/icon.icns +0 -0
  5. package/assets/icon.ico +0 -0
  6. package/assets/icon.png +0 -0
  7. package/components.json +21 -0
  8. package/electron/backend-env.cjs +112 -0
  9. package/electron/backend-env.test.cjs +111 -0
  10. package/electron/backend-probes.cjs +106 -0
  11. package/electron/backend-probes.test.cjs +82 -0
  12. package/electron/backend-ready.cjs +66 -0
  13. package/electron/bootstrap-platform.cjs +91 -0
  14. package/electron/bootstrap-platform.test.cjs +111 -0
  15. package/electron/bootstrap-runner.cjs +720 -0
  16. package/electron/bootstrap-runner.test.cjs +138 -0
  17. package/electron/connection-config.cjs +254 -0
  18. package/electron/connection-config.test.cjs +329 -0
  19. package/electron/dashboard-token.cjs +99 -0
  20. package/electron/dashboard-token.test.cjs +142 -0
  21. package/electron/desktop-uninstall.cjs +232 -0
  22. package/electron/desktop-uninstall.test.cjs +246 -0
  23. package/electron/entitlements.mac.inherit.plist +14 -0
  24. package/electron/entitlements.mac.plist +14 -0
  25. package/electron/fs-read-dir.cjs +109 -0
  26. package/electron/fs-read-dir.test.cjs +364 -0
  27. package/electron/gateway-ws-probe.cjs +188 -0
  28. package/electron/gateway-ws-probe.test.cjs +122 -0
  29. package/electron/git-root.cjs +54 -0
  30. package/electron/git-root.test.cjs +40 -0
  31. package/electron/git-worktrees.cjs +174 -0
  32. package/electron/hardening.cjs +184 -0
  33. package/electron/hardening.test.cjs +116 -0
  34. package/electron/main.cjs +5762 -0
  35. package/electron/oauth-net-request.cjs +20 -0
  36. package/electron/oauth-net-request.test.cjs +34 -0
  37. package/electron/preload.cjs +135 -0
  38. package/electron/session-windows.cjs +99 -0
  39. package/electron/session-windows.test.cjs +177 -0
  40. package/electron/update-remote.cjs +56 -0
  41. package/electron/update-remote.test.cjs +78 -0
  42. package/electron/vscode-marketplace.cjs +331 -0
  43. package/electron/vscode-marketplace.test.cjs +113 -0
  44. package/electron/windows-child-process.test.cjs +57 -0
  45. package/electron/windows-user-env.cjs +76 -0
  46. package/electron/windows-user-env.test.cjs +90 -0
  47. package/electron/workspace-cwd.cjs +38 -0
  48. package/electron/workspace-cwd.test.cjs +45 -0
  49. package/eslint.config.mjs +122 -0
  50. package/index.html +17 -0
  51. package/package.json +254 -0
  52. package/pr-assets/session-source-folders.png +0 -0
  53. package/preview-demo.html +65 -0
  54. package/public/apple-touch-icon.png +0 -0
  55. package/public/ds-assets/filler-bg0.jpg +0 -0
  56. package/public/nastech-frames/nastech-frame-0.png +0 -0
  57. package/public/nastech-frames/nastech-frame-1.png +0 -0
  58. package/public/nastech-frames/nastech-frame-2.png +0 -0
  59. package/public/nastech-frames/nastech-frame-3.png +0 -0
  60. package/public/nastech-frames/nastech-frame-4.png +0 -0
  61. package/public/nastech-frames/nastech-frame-5.png +0 -0
  62. package/public/nastech-frames/nastech-frame-6.png +0 -0
  63. package/public/nastech-frames/nastech-frame-7.png +0 -0
  64. package/public/nastech-girl.jpg +0 -0
  65. package/public/nastech-sprite.png +0 -0
  66. package/public/nastech.png +0 -0
  67. package/scripts/after-pack.cjs +41 -0
  68. package/scripts/assert-dist-built.cjs +70 -0
  69. package/scripts/assert-dist-built.test.cjs +84 -0
  70. package/scripts/assert-root-install.cjs +13 -0
  71. package/scripts/before-build.cjs +11 -0
  72. package/scripts/before-pack.cjs +78 -0
  73. package/scripts/before-pack.test.cjs +53 -0
  74. package/scripts/click-session.mjs +51 -0
  75. package/scripts/dev-no-hmr.mjs +22 -0
  76. package/scripts/diag-jump.mjs +115 -0
  77. package/scripts/diag-scroll-reset.mjs +229 -0
  78. package/scripts/eval.mjs +21 -0
  79. package/scripts/leak-typing.mjs +222 -0
  80. package/scripts/measure-jump.mjs +108 -0
  81. package/scripts/measure-latency.mjs +184 -0
  82. package/scripts/measure-real-stream.mjs +252 -0
  83. package/scripts/measure-submit.mjs +179 -0
  84. package/scripts/measure-synthetic-stream.mjs +322 -0
  85. package/scripts/notarize-artifact.cjs +77 -0
  86. package/scripts/notarize.cjs +100 -0
  87. package/scripts/patch-electron-builder-mac-binary.cjs +59 -0
  88. package/scripts/probe-renderer.mjs +38 -0
  89. package/scripts/probe-thread.mjs +40 -0
  90. package/scripts/profile-long-stream.mjs +191 -0
  91. package/scripts/profile-real-stream.mjs +137 -0
  92. package/scripts/profile-synth-stream.mjs +103 -0
  93. package/scripts/profile-typing-lag.md +381 -0
  94. package/scripts/profile-typing.mjs +260 -0
  95. package/scripts/reload-renderer.mjs +25 -0
  96. package/scripts/reload.mjs +36 -0
  97. package/scripts/set-exe-identity.cjs +94 -0
  98. package/scripts/stage-native-deps.cjs +159 -0
  99. package/scripts/test-desktop.mjs +425 -0
  100. package/scripts/write-build-stamp.cjs +126 -0
  101. package/src/app/agents/index.tsx +398 -0
  102. package/src/app/artifacts/index.test.ts +62 -0
  103. package/src/app/artifacts/index.tsx +906 -0
  104. package/src/app/chat/chat-drop-overlay.tsx +48 -0
  105. package/src/app/chat/chat-swap-overlay.tsx +47 -0
  106. package/src/app/chat/composer/attachments.tsx +114 -0
  107. package/src/app/chat/composer/completion-drawer.tsx +63 -0
  108. package/src/app/chat/composer/context-menu.tsx +172 -0
  109. package/src/app/chat/composer/controls.tsx +289 -0
  110. package/src/app/chat/composer/drop-affordance.ts +2 -0
  111. package/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
  112. package/src/app/chat/composer/focus.ts +134 -0
  113. package/src/app/chat/composer/help-hint.tsx +59 -0
  114. package/src/app/chat/composer/hooks/use-at-completions.ts +141 -0
  115. package/src/app/chat/composer/hooks/use-live-completion-adapter.ts +119 -0
  116. package/src/app/chat/composer/hooks/use-mic-recorder.ts +291 -0
  117. package/src/app/chat/composer/hooks/use-slash-completions.ts +114 -0
  118. package/src/app/chat/composer/hooks/use-voice-conversation.ts +390 -0
  119. package/src/app/chat/composer/hooks/use-voice-recorder.ts +116 -0
  120. package/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
  121. package/src/app/chat/composer/index.tsx +1611 -0
  122. package/src/app/chat/composer/inline-refs.ts +138 -0
  123. package/src/app/chat/composer/model-pill.tsx +86 -0
  124. package/src/app/chat/composer/queue-panel.tsx +130 -0
  125. package/src/app/chat/composer/rich-editor.test.ts +18 -0
  126. package/src/app/chat/composer/rich-editor.ts +165 -0
  127. package/src/app/chat/composer/skin-slash-popover.tsx +61 -0
  128. package/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
  129. package/src/app/chat/composer/status-stack/index.tsx +202 -0
  130. package/src/app/chat/composer/status-stack/status-row.tsx +155 -0
  131. package/src/app/chat/composer/text-utils.test.ts +77 -0
  132. package/src/app/chat/composer/text-utils.ts +107 -0
  133. package/src/app/chat/composer/trigger-popover.test.tsx +42 -0
  134. package/src/app/chat/composer/trigger-popover.tsx +116 -0
  135. package/src/app/chat/composer/types.ts +64 -0
  136. package/src/app/chat/composer/url-dialog.tsx +82 -0
  137. package/src/app/chat/composer/voice-activity.tsx +252 -0
  138. package/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
  139. package/src/app/chat/hooks/use-composer-actions.ts +525 -0
  140. package/src/app/chat/hooks/use-file-drop-zone.ts +118 -0
  141. package/src/app/chat/index.tsx +390 -0
  142. package/src/app/chat/perf-probe.tsx +269 -0
  143. package/src/app/chat/right-rail/index.ts +1 -0
  144. package/src/app/chat/right-rail/preview-console-state.ts +82 -0
  145. package/src/app/chat/right-rail/preview-console.tsx +290 -0
  146. package/src/app/chat/right-rail/preview-file.tsx +559 -0
  147. package/src/app/chat/right-rail/preview-pane.test.tsx +43 -0
  148. package/src/app/chat/right-rail/preview-pane.tsx +657 -0
  149. package/src/app/chat/right-rail/preview.tsx +171 -0
  150. package/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
  151. package/src/app/chat/scroll-to-bottom-button.tsx +74 -0
  152. package/src/app/chat/sidebar/cron-jobs-section.tsx +325 -0
  153. package/src/app/chat/sidebar/index.tsx +1219 -0
  154. package/src/app/chat/sidebar/load-more-row.tsx +30 -0
  155. package/src/app/chat/sidebar/order.test.ts +21 -0
  156. package/src/app/chat/sidebar/order.ts +17 -0
  157. package/src/app/chat/sidebar/profile-switcher.tsx +516 -0
  158. package/src/app/chat/sidebar/session-actions-menu.tsx +264 -0
  159. package/src/app/chat/sidebar/session-row.tsx +257 -0
  160. package/src/app/chat/sidebar/virtual-session-list.tsx +154 -0
  161. package/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
  162. package/src/app/chat/sidebar/workspace-groups.ts +326 -0
  163. package/src/app/chat/thread-loading.test.ts +34 -0
  164. package/src/app/chat/thread-loading.ts +26 -0
  165. package/src/app/command-center/index.tsx +654 -0
  166. package/src/app/command-palette/index.tsx +513 -0
  167. package/src/app/command-palette/marketplace-theme-page.tsx +157 -0
  168. package/src/app/cron/index.tsx +942 -0
  169. package/src/app/cron/job-state.ts +29 -0
  170. package/src/app/desktop-controller.tsx +938 -0
  171. package/src/app/floating-hud.ts +22 -0
  172. package/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
  173. package/src/app/gateway/hooks/use-gateway-boot.ts +387 -0
  174. package/src/app/gateway/hooks/use-gateway-request.ts +138 -0
  175. package/src/app/hooks/use-keybinds.ts +186 -0
  176. package/src/app/hooks/use-refresh-hotkey.ts +45 -0
  177. package/src/app/hooks/use-route-enum-param.ts +38 -0
  178. package/src/app/index.tsx +1 -0
  179. package/src/app/layout-constants.ts +13 -0
  180. package/src/app/messaging/index.tsx +648 -0
  181. package/src/app/messaging/platform-icon.tsx +93 -0
  182. package/src/app/model-picker-overlay.tsx +42 -0
  183. package/src/app/model-visibility-overlay.tsx +31 -0
  184. package/src/app/overlays/overlay-chrome.tsx +66 -0
  185. package/src/app/overlays/overlay-search-input.tsx +33 -0
  186. package/src/app/overlays/overlay-split-layout.tsx +130 -0
  187. package/src/app/overlays/overlay-view.tsx +91 -0
  188. package/src/app/page-search-shell.tsx +75 -0
  189. package/src/app/profiles/create-profile-dialog.tsx +154 -0
  190. package/src/app/profiles/delete-profile-dialog.tsx +65 -0
  191. package/src/app/profiles/index.tsx +671 -0
  192. package/src/app/profiles/rename-profile-dialog.tsx +125 -0
  193. package/src/app/right-sidebar/files/dnd-manager.ts +27 -0
  194. package/src/app/right-sidebar/files/ipc.test.ts +100 -0
  195. package/src/app/right-sidebar/files/ipc.ts +161 -0
  196. package/src/app/right-sidebar/files/remote-picker.tsx +177 -0
  197. package/src/app/right-sidebar/files/tree.tsx +224 -0
  198. package/src/app/right-sidebar/files/use-project-tree.test.ts +190 -0
  199. package/src/app/right-sidebar/files/use-project-tree.ts +268 -0
  200. package/src/app/right-sidebar/index.test.tsx +75 -0
  201. package/src/app/right-sidebar/index.tsx +395 -0
  202. package/src/app/right-sidebar/store.ts +15 -0
  203. package/src/app/right-sidebar/terminal/buffer.ts +65 -0
  204. package/src/app/right-sidebar/terminal/index.tsx +98 -0
  205. package/src/app/right-sidebar/terminal/persistent.tsx +122 -0
  206. package/src/app/right-sidebar/terminal/selection.ts +75 -0
  207. package/src/app/right-sidebar/terminal/use-terminal-session.ts +504 -0
  208. package/src/app/routes.ts +88 -0
  209. package/src/app/session/hooks/use-context-suggestions.ts +58 -0
  210. package/src/app/session/hooks/use-cwd-actions.ts +109 -0
  211. package/src/app/session/hooks/use-message-stream.ts +957 -0
  212. package/src/app/session/hooks/use-model-controls.test.tsx +198 -0
  213. package/src/app/session/hooks/use-model-controls.ts +106 -0
  214. package/src/app/session/hooks/use-nastech-config.ts +74 -0
  215. package/src/app/session/hooks/use-preview-routing.test.tsx +168 -0
  216. package/src/app/session/hooks/use-preview-routing.ts +223 -0
  217. package/src/app/session/hooks/use-prompt-actions.test.tsx +316 -0
  218. package/src/app/session/hooks/use-prompt-actions.ts +1030 -0
  219. package/src/app/session/hooks/use-route-resume.test.tsx +136 -0
  220. package/src/app/session/hooks/use-route-resume.ts +115 -0
  221. package/src/app/session/hooks/use-session-actions.test.tsx +119 -0
  222. package/src/app/session/hooks/use-session-actions.ts +885 -0
  223. package/src/app/session/hooks/use-session-state-cache.test.tsx +118 -0
  224. package/src/app/session/hooks/use-session-state-cache.ts +191 -0
  225. package/src/app/session-picker-overlay.tsx +32 -0
  226. package/src/app/session-switcher.tsx +107 -0
  227. package/src/app/settings/about-settings.tsx +173 -0
  228. package/src/app/settings/appearance-settings.tsx +162 -0
  229. package/src/app/settings/config-settings.tsx +384 -0
  230. package/src/app/settings/constants.ts +545 -0
  231. package/src/app/settings/credential-key-ui.tsx +373 -0
  232. package/src/app/settings/env-credentials.tsx +198 -0
  233. package/src/app/settings/env-var-actions-menu.tsx +136 -0
  234. package/src/app/settings/field-copy.ts +56 -0
  235. package/src/app/settings/gateway-settings.tsx +620 -0
  236. package/src/app/settings/helpers.test.ts +138 -0
  237. package/src/app/settings/helpers.ts +151 -0
  238. package/src/app/settings/index.tsx +237 -0
  239. package/src/app/settings/keys-settings.tsx +96 -0
  240. package/src/app/settings/mcp-settings.tsx +271 -0
  241. package/src/app/settings/model-settings.test.tsx +157 -0
  242. package/src/app/settings/model-settings.tsx +559 -0
  243. package/src/app/settings/notifications-settings.tsx +150 -0
  244. package/src/app/settings/primitives.tsx +115 -0
  245. package/src/app/settings/providers-settings.test.tsx +100 -0
  246. package/src/app/settings/providers-settings.tsx +258 -0
  247. package/src/app/settings/sessions-settings.tsx +276 -0
  248. package/src/app/settings/toolset-config-panel.test.tsx +289 -0
  249. package/src/app/settings/toolset-config-panel.tsx +449 -0
  250. package/src/app/settings/types.ts +42 -0
  251. package/src/app/settings/uninstall-section.tsx +185 -0
  252. package/src/app/settings/use-deep-link-highlight.ts +60 -0
  253. package/src/app/shell/app-shell.tsx +167 -0
  254. package/src/app/shell/gateway-menu-panel.tsx +150 -0
  255. package/src/app/shell/hooks/use-overlay-routing.ts +71 -0
  256. package/src/app/shell/hooks/use-status-snapshot.ts +57 -0
  257. package/src/app/shell/hooks/use-statusbar-items.tsx +403 -0
  258. package/src/app/shell/keybind-panel.tsx +220 -0
  259. package/src/app/shell/model-edit-submenu.test.tsx +84 -0
  260. package/src/app/shell/model-edit-submenu.tsx +245 -0
  261. package/src/app/shell/model-menu-panel.tsx +295 -0
  262. package/src/app/shell/sidebar-label.tsx +22 -0
  263. package/src/app/shell/statusbar-controls.tsx +185 -0
  264. package/src/app/shell/titlebar-controls.tsx +244 -0
  265. package/src/app/shell/titlebar.test.ts +26 -0
  266. package/src/app/shell/titlebar.ts +45 -0
  267. package/src/app/shell/use-group-registry.ts +39 -0
  268. package/src/app/skills/index.test.tsx +103 -0
  269. package/src/app/skills/index.tsx +371 -0
  270. package/src/app/types.ts +99 -0
  271. package/src/app/updates-overlay.tsx +369 -0
  272. package/src/components/Backdrop.tsx +114 -0
  273. package/src/components/assistant-ui/ansi-text.tsx +34 -0
  274. package/src/components/assistant-ui/clarify-tool.tsx +281 -0
  275. package/src/components/assistant-ui/directive-text.test.ts +39 -0
  276. package/src/components/assistant-ui/directive-text.tsx +389 -0
  277. package/src/components/assistant-ui/markdown-text.test.ts +204 -0
  278. package/src/components/assistant-ui/markdown-text.tsx +497 -0
  279. package/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
  280. package/src/components/assistant-ui/message-render-boundary.tsx +48 -0
  281. package/src/components/assistant-ui/streaming.test.tsx +739 -0
  282. package/src/components/assistant-ui/thread-list.tsx +307 -0
  283. package/src/components/assistant-ui/thread-virtualizer.tsx +512 -0
  284. package/src/components/assistant-ui/thread.tsx +1474 -0
  285. package/src/components/assistant-ui/todo-tool.tsx +109 -0
  286. package/src/components/assistant-ui/tool-approval-group.test.tsx +158 -0
  287. package/src/components/assistant-ui/tool-approval.test.tsx +81 -0
  288. package/src/components/assistant-ui/tool-approval.tsx +209 -0
  289. package/src/components/assistant-ui/tool-fallback-model.test.ts +66 -0
  290. package/src/components/assistant-ui/tool-fallback-model.ts +1368 -0
  291. package/src/components/assistant-ui/tool-fallback.tsx +466 -0
  292. package/src/components/assistant-ui/tooltip-icon-button.tsx +33 -0
  293. package/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
  294. package/src/components/assistant-ui/user-message-text.tsx +150 -0
  295. package/src/components/boot-failure-overlay.tsx +246 -0
  296. package/src/components/boot-failure-reauth.test.ts +100 -0
  297. package/src/components/boot-failure-reauth.ts +81 -0
  298. package/src/components/brand-mark.tsx +19 -0
  299. package/src/components/chat/activity-timer-text.tsx +24 -0
  300. package/src/components/chat/activity-timer.test.tsx +43 -0
  301. package/src/components/chat/activity-timer.ts +64 -0
  302. package/src/components/chat/code-card.tsx +78 -0
  303. package/src/components/chat/compact-markdown.tsx +113 -0
  304. package/src/components/chat/composer-dock.ts +31 -0
  305. package/src/components/chat/diff-lines.tsx +54 -0
  306. package/src/components/chat/disclosure-row.tsx +63 -0
  307. package/src/components/chat/generated-image-context.tsx +19 -0
  308. package/src/components/chat/generated-image-result.tsx +174 -0
  309. package/src/components/chat/image-generation-placeholder.tsx +279 -0
  310. package/src/components/chat/intro-copy.jsonl +75 -0
  311. package/src/components/chat/intro.tsx +182 -0
  312. package/src/components/chat/preview-attachment.tsx +125 -0
  313. package/src/components/chat/shiki-highlighter.tsx +107 -0
  314. package/src/components/chat/status-row.tsx +70 -0
  315. package/src/components/chat/status-section.tsx +42 -0
  316. package/src/components/chat/terminal-output.tsx +50 -0
  317. package/src/components/chat/zoomable-image.tsx +177 -0
  318. package/src/components/desktop-install-overlay.tsx +595 -0
  319. package/src/components/desktop-onboarding-overlay.test.tsx +100 -0
  320. package/src/components/desktop-onboarding-overlay.tsx +1286 -0
  321. package/src/components/error-boundary.tsx +77 -0
  322. package/src/components/gateway-connecting-overlay.test.tsx +143 -0
  323. package/src/components/gateway-connecting-overlay.tsx +183 -0
  324. package/src/components/haptics-provider.tsx +19 -0
  325. package/src/components/language-switcher.test.tsx +53 -0
  326. package/src/components/language-switcher.tsx +175 -0
  327. package/src/components/model-picker.tsx +340 -0
  328. package/src/components/model-visibility-dialog.tsx +155 -0
  329. package/src/components/notifications.tsx +196 -0
  330. package/src/components/page-loader.tsx +34 -0
  331. package/src/components/pane-shell/context.ts +14 -0
  332. package/src/components/pane-shell/index.ts +4 -0
  333. package/src/components/pane-shell/pane-shell.test.tsx +333 -0
  334. package/src/components/pane-shell/pane-shell.tsx +330 -0
  335. package/src/components/prompt-overlays.tsx +234 -0
  336. package/src/components/session-picker.tsx +108 -0
  337. package/src/components/status-dot.tsx +26 -0
  338. package/src/components/ui/action-status.tsx +25 -0
  339. package/src/components/ui/alert.tsx +53 -0
  340. package/src/components/ui/badge.tsx +35 -0
  341. package/src/components/ui/braille-spinner.tsx +61 -0
  342. package/src/components/ui/button.tsx +81 -0
  343. package/src/components/ui/checkbox.tsx +27 -0
  344. package/src/components/ui/codicon.tsx +20 -0
  345. package/src/components/ui/command.tsx +111 -0
  346. package/src/components/ui/confirm-dialog.tsx +109 -0
  347. package/src/components/ui/context-menu.tsx +141 -0
  348. package/src/components/ui/control.ts +25 -0
  349. package/src/components/ui/copy-button.test.tsx +36 -0
  350. package/src/components/ui/copy-button.tsx +229 -0
  351. package/src/components/ui/dialog.tsx +152 -0
  352. package/src/components/ui/disclosure-caret.tsx +20 -0
  353. package/src/components/ui/dropdown-menu.tsx +291 -0
  354. package/src/components/ui/error-state.tsx +50 -0
  355. package/src/components/ui/fade-text.tsx +110 -0
  356. package/src/components/ui/glyph-spinner.tsx +63 -0
  357. package/src/components/ui/input.tsx +22 -0
  358. package/src/components/ui/kbd.tsx +37 -0
  359. package/src/components/ui/loader.tsx +558 -0
  360. package/src/components/ui/log-view.tsx +17 -0
  361. package/src/components/ui/pagination.tsx +114 -0
  362. package/src/components/ui/popover.tsx +44 -0
  363. package/src/components/ui/scroll-area.tsx +43 -0
  364. package/src/components/ui/search-field.tsx +80 -0
  365. package/src/components/ui/segmented-control.tsx +51 -0
  366. package/src/components/ui/select.tsx +92 -0
  367. package/src/components/ui/separator.tsx +26 -0
  368. package/src/components/ui/sheet.tsx +116 -0
  369. package/src/components/ui/sidebar.tsx +674 -0
  370. package/src/components/ui/skeleton.tsx +7 -0
  371. package/src/components/ui/switch.tsx +49 -0
  372. package/src/components/ui/tabs.tsx +36 -0
  373. package/src/components/ui/text-tab.tsx +43 -0
  374. package/src/components/ui/textarea.tsx +11 -0
  375. package/src/components/ui/tool-icon.tsx +65 -0
  376. package/src/components/ui/tooltip.tsx +69 -0
  377. package/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
  378. package/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
  379. package/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
  380. package/src/global.d.ts +457 -0
  381. package/src/hooks/use-image-download.ts +85 -0
  382. package/src/hooks/use-media-query.ts +24 -0
  383. package/src/hooks/use-mobile.ts +3 -0
  384. package/src/hooks/use-resize-observer.ts +38 -0
  385. package/src/hooks/use-worktree-info.ts +68 -0
  386. package/src/i18n/catalog.ts +12 -0
  387. package/src/i18n/context.test.tsx +232 -0
  388. package/src/i18n/context.tsx +183 -0
  389. package/src/i18n/define-locale.ts +41 -0
  390. package/src/i18n/en.ts +1779 -0
  391. package/src/i18n/index.ts +20 -0
  392. package/src/i18n/ja.ts +1890 -0
  393. package/src/i18n/languages.test.ts +43 -0
  394. package/src/i18n/languages.ts +86 -0
  395. package/src/i18n/runtime.test.ts +75 -0
  396. package/src/i18n/runtime.ts +53 -0
  397. package/src/i18n/types.ts +1452 -0
  398. package/src/i18n/zh-hant.ts +1849 -0
  399. package/src/i18n/zh.ts +1923 -0
  400. package/src/lib/ansi.test.ts +123 -0
  401. package/src/lib/ansi.ts +175 -0
  402. package/src/lib/chat-messages.test.ts +708 -0
  403. package/src/lib/chat-messages.ts +885 -0
  404. package/src/lib/chat-runtime.test.ts +18 -0
  405. package/src/lib/chat-runtime.ts +335 -0
  406. package/src/lib/clipboard.ts +28 -0
  407. package/src/lib/commit-changelog.test.ts +114 -0
  408. package/src/lib/commit-changelog.ts +177 -0
  409. package/src/lib/completion-sound.ts +519 -0
  410. package/src/lib/desktop-fs.test.ts +116 -0
  411. package/src/lib/desktop-fs.ts +113 -0
  412. package/src/lib/desktop-slash-commands.test.ts +126 -0
  413. package/src/lib/desktop-slash-commands.ts +286 -0
  414. package/src/lib/embedded-images.test.ts +35 -0
  415. package/src/lib/embedded-images.ts +60 -0
  416. package/src/lib/external-link.test.tsx +168 -0
  417. package/src/lib/external-link.tsx +303 -0
  418. package/src/lib/gateway-events.test.ts +27 -0
  419. package/src/lib/gateway-events.ts +49 -0
  420. package/src/lib/gateway-ws-url.test.ts +78 -0
  421. package/src/lib/gateway-ws-url.ts +91 -0
  422. package/src/lib/generated-images.test.ts +97 -0
  423. package/src/lib/generated-images.ts +116 -0
  424. package/src/lib/haptics.ts +129 -0
  425. package/src/lib/icons.ts +203 -0
  426. package/src/lib/incremental-external-store-runtime.ts +188 -0
  427. package/src/lib/katex-memo.ts +260 -0
  428. package/src/lib/keybinds/actions.ts +125 -0
  429. package/src/lib/keybinds/combo.test.ts +86 -0
  430. package/src/lib/keybinds/combo.ts +169 -0
  431. package/src/lib/local-preview.ts +126 -0
  432. package/src/lib/markdown-code.test.ts +23 -0
  433. package/src/lib/markdown-code.ts +195 -0
  434. package/src/lib/markdown-preprocess.ts +386 -0
  435. package/src/lib/media.remote.test.ts +58 -0
  436. package/src/lib/media.ts +111 -0
  437. package/src/lib/model-status-label.test.ts +31 -0
  438. package/src/lib/model-status-label.ts +103 -0
  439. package/src/lib/mutable-ref.ts +6 -0
  440. package/src/lib/preview-targets.test.ts +27 -0
  441. package/src/lib/preview-targets.ts +63 -0
  442. package/src/lib/profile-color.ts +58 -0
  443. package/src/lib/provider-setup-errors.test.ts +26 -0
  444. package/src/lib/provider-setup-errors.ts +12 -0
  445. package/src/lib/query-client.ts +13 -0
  446. package/src/lib/remend-tail.test.ts +105 -0
  447. package/src/lib/remend-tail.ts +108 -0
  448. package/src/lib/runtime-readiness.test.ts +65 -0
  449. package/src/lib/runtime-readiness.ts +147 -0
  450. package/src/lib/session-export.ts +57 -0
  451. package/src/lib/session-search.test.ts +58 -0
  452. package/src/lib/session-search.ts +19 -0
  453. package/src/lib/session-source.ts +62 -0
  454. package/src/lib/speech-text.ts +35 -0
  455. package/src/lib/statusbar.ts +91 -0
  456. package/src/lib/storage.test.ts +25 -0
  457. package/src/lib/storage.ts +107 -0
  458. package/src/lib/todos.test.ts +35 -0
  459. package/src/lib/todos.ts +51 -0
  460. package/src/lib/tool-result-summary.test.ts +106 -0
  461. package/src/lib/tool-result-summary.ts +467 -0
  462. package/src/lib/update-copy.test.ts +38 -0
  463. package/src/lib/update-copy.ts +44 -0
  464. package/src/lib/use-enter-animation.ts +100 -0
  465. package/src/lib/utils.ts +6 -0
  466. package/src/lib/voice-playback.ts +128 -0
  467. package/src/lib/yolo-session.ts +26 -0
  468. package/src/main.tsx +43 -0
  469. package/src/nastech.test.ts +49 -0
  470. package/src/nastech.ts +718 -0
  471. package/src/store/activity.ts +100 -0
  472. package/src/store/boot.ts +91 -0
  473. package/src/store/clarify.test.ts +81 -0
  474. package/src/store/clarify.ts +69 -0
  475. package/src/store/command-palette.ts +20 -0
  476. package/src/store/compaction.test.ts +53 -0
  477. package/src/store/compaction.ts +38 -0
  478. package/src/store/completion-sound.ts +32 -0
  479. package/src/store/composer-input-history.test.ts +147 -0
  480. package/src/store/composer-input-history.ts +158 -0
  481. package/src/store/composer-queue.test.ts +148 -0
  482. package/src/store/composer-queue.ts +239 -0
  483. package/src/store/composer-status.test.ts +99 -0
  484. package/src/store/composer-status.ts +277 -0
  485. package/src/store/composer.test.ts +106 -0
  486. package/src/store/composer.ts +184 -0
  487. package/src/store/cron.ts +19 -0
  488. package/src/store/gateway.ts +290 -0
  489. package/src/store/haptics.ts +17 -0
  490. package/src/store/keybinds.ts +139 -0
  491. package/src/store/layout.ts +176 -0
  492. package/src/store/model-presets.test.ts +51 -0
  493. package/src/store/model-presets.ts +86 -0
  494. package/src/store/model-visibility.test.ts +37 -0
  495. package/src/store/model-visibility.ts +108 -0
  496. package/src/store/native-notifications.test.ts +192 -0
  497. package/src/store/native-notifications.ts +203 -0
  498. package/src/store/notifications.ts +165 -0
  499. package/src/store/onboarding.test.ts +372 -0
  500. package/src/store/onboarding.ts +866 -0
  501. package/src/store/panes.test.ts +146 -0
  502. package/src/store/panes.ts +145 -0
  503. package/src/store/preview.test.ts +135 -0
  504. package/src/store/preview.ts +466 -0
  505. package/src/store/profile.test.ts +89 -0
  506. package/src/store/profile.ts +365 -0
  507. package/src/store/prompts.test.ts +121 -0
  508. package/src/store/prompts.ts +115 -0
  509. package/src/store/session-switcher.test.ts +115 -0
  510. package/src/store/session-switcher.ts +128 -0
  511. package/src/store/session-sync.ts +25 -0
  512. package/src/store/session.test.ts +131 -0
  513. package/src/store/session.ts +255 -0
  514. package/src/store/subagents.test.ts +111 -0
  515. package/src/store/subagents.ts +260 -0
  516. package/src/store/thread-scroll.ts +46 -0
  517. package/src/store/todos.test.ts +47 -0
  518. package/src/store/todos.ts +64 -0
  519. package/src/store/tool-diffs.ts +23 -0
  520. package/src/store/tool-dismiss.ts +45 -0
  521. package/src/store/tool-view.ts +91 -0
  522. package/src/store/translucency.ts +38 -0
  523. package/src/store/updates.test.ts +77 -0
  524. package/src/store/updates.ts +315 -0
  525. package/src/store/voice-playback.ts +24 -0
  526. package/src/store/windows.test.ts +143 -0
  527. package/src/store/windows.ts +77 -0
  528. package/src/styles.css +1235 -0
  529. package/src/themes/color.ts +142 -0
  530. package/src/themes/context.tsx +339 -0
  531. package/src/themes/index.ts +3 -0
  532. package/src/themes/install.test.ts +119 -0
  533. package/src/themes/install.ts +95 -0
  534. package/src/themes/presets.test.ts +33 -0
  535. package/src/themes/presets.ts +293 -0
  536. package/src/themes/profile-theme.test.ts +41 -0
  537. package/src/themes/types.ts +66 -0
  538. package/src/themes/use-skin-command.ts +60 -0
  539. package/src/themes/user-themes.test.ts +63 -0
  540. package/src/themes/user-themes.ts +122 -0
  541. package/src/themes/vscode.test.ts +171 -0
  542. package/src/themes/vscode.ts +343 -0
  543. package/src/types/nastech.ts +646 -0
  544. package/src/vite-env.d.ts +1 -0
  545. package/tsconfig.json +25 -0
  546. package/vite.config.ts +56 -0
@@ -0,0 +1,86 @@
1
+ import { atom } from 'nanostores'
2
+
3
+ import { persistString, storedString } from '@/lib/storage'
4
+
5
+ import { notifyError } from './notifications'
6
+ import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
7
+
8
+ const STORAGE_KEY = 'nastech.desktop.model-presets'
9
+
10
+ /** Per-model reasoning/fast preset, remembered globally across sessions and
11
+ * re-applied to the session whenever that model is selected. Unset dimensions
12
+ * fall back to the NasTech default (medium effort, no fast). */
13
+ export interface ModelPreset {
14
+ effort?: string
15
+ fast?: boolean
16
+ }
17
+
18
+ type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
19
+
20
+ /** Stable `provider::model` key (matches the visibility-store format). */
21
+ export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}`
22
+
23
+ function load(): Record<string, ModelPreset> {
24
+ const raw = storedString(STORAGE_KEY)
25
+
26
+ if (!raw) {
27
+ return {}
28
+ }
29
+
30
+ try {
31
+ const parsed = JSON.parse(raw)
32
+
33
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, ModelPreset>) : {}
34
+ } catch {
35
+ return {}
36
+ }
37
+ }
38
+
39
+ export const $modelPresets = atom<Record<string, ModelPreset>>(load())
40
+
41
+ export function getModelPreset(provider: string, model: string): ModelPreset {
42
+ return $modelPresets.get()[modelPresetKey(provider, model)] ?? {}
43
+ }
44
+
45
+ /** Merge a partial preset for one model and persist. */
46
+ export function setModelPreset(provider: string, model: string, patch: ModelPreset): void {
47
+ const key = modelPresetKey(provider, model)
48
+ const next = { ...$modelPresets.get(), [key]: { ...$modelPresets.get()[key], ...patch } }
49
+
50
+ $modelPresets.set(next)
51
+ persistString(STORAGE_KEY, JSON.stringify(next))
52
+ }
53
+
54
+ /** Push a model's preset onto the active session (optimistic + gateway).
55
+ * `undefined` skips that dimension; values are capability-gated upstream.
56
+ * No-ops without a session — the gateway's `config.set` reasoning/fast fall
57
+ * back to persistent (global/profile) config when none matches, so selecting
58
+ * a model must not reach it (else it rewrites `agent.*`, defaults included). */
59
+ export async function applyModelPreset(
60
+ { effort, fast }: ModelPreset,
61
+ ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
62
+ ): Promise<void> {
63
+ if (!ctx.sessionId) {
64
+ return
65
+ }
66
+
67
+ if (effort !== undefined) {
68
+ setCurrentReasoningEffort(effort)
69
+ }
70
+
71
+ if (fast !== undefined) {
72
+ setCurrentFastMode(fast)
73
+ }
74
+
75
+ try {
76
+ if (effort !== undefined) {
77
+ await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort })
78
+ }
79
+
80
+ if (fast !== undefined) {
81
+ await ctx.request('config.set', { key: 'fast', session_id: ctx.sessionId, value: fast ? 'fast' : 'normal' })
82
+ }
83
+ } catch (err) {
84
+ notifyError(err, ctx.failMessage)
85
+ }
86
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { ModelOptionProvider } from '@/types/nastech'
4
+
5
+ import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility'
6
+
7
+ const provider = (slug: string, models: string[]): ModelOptionProvider => ({
8
+ models,
9
+ name: slug,
10
+ slug
11
+ })
12
+
13
+ describe('model visibility', () => {
14
+ it('keeps newly configured providers visible when stored choices are stale', () => {
15
+ const stored = new Set([modelVisibilityKey('copilot', 'claude-sonnet-4.6')])
16
+
17
+ const visible = effectiveVisibleKeys(stored, [
18
+ provider('copilot', ['claude-sonnet-4.6']),
19
+ provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])
20
+ ])
21
+
22
+ expect(visible.has(modelVisibilityKey('copilot', 'claude-sonnet-4.6'))).toBe(true)
23
+ expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
24
+ expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(true)
25
+ })
26
+
27
+ it('does not re-add models from a provider that already has stored choices', () => {
28
+ const stored = new Set([modelVisibilityKey('local-ollama', 'qwen3:latest')])
29
+
30
+ const visible = effectiveVisibleKeys(stored, [
31
+ provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])
32
+ ])
33
+
34
+ expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
35
+ expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
36
+ })
37
+ })
@@ -0,0 +1,108 @@
1
+ import { atom } from 'nanostores'
2
+
3
+ import { persistString, storedString } from '@/lib/storage'
4
+ import type { ModelOptionProvider } from '@/types/nastech'
5
+
6
+ const STORAGE_KEY = 'NASTECH.desktop.visible-models'
7
+
8
+ /** Models shown per provider in the status-bar dropdown before the user has
9
+ * customized the list. Backend `models` are already relevance-ordered. */
10
+ export const DEFAULT_VISIBLE_PER_PROVIDER = 50
11
+
12
+ /** Stable key for a provider/model pair (`::` avoids colliding with model ids
13
+ * that contain a single colon, e.g. `model:tag`). */
14
+ export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
15
+
16
+ /** A model and its optional `…-fast` sibling, collapsed into one logical row.
17
+ * `id` is the canonical (base) model; `fastId` is the fast variant if present. */
18
+ export interface ModelFamily {
19
+ fastId: string | null
20
+ id: string
21
+ }
22
+
23
+ /** Collapse a provider's model list so a base model and its `…-fast` variant
24
+ * become a single family (one row, one toggle). Order is preserved by the
25
+ * base model's position. A `…-fast` model with no base stands on its own. */
26
+ export function collapseModelFamilies(models: readonly string[]): ModelFamily[] {
27
+ const present = new Set(models)
28
+ const families: ModelFamily[] = []
29
+ const consumed = new Set<string>()
30
+
31
+ for (const model of models) {
32
+ if (consumed.has(model)) {
33
+ continue
34
+ }
35
+
36
+ if (/-fast$/i.test(model) && present.has(model.replace(/-fast$/i, ''))) {
37
+ // Represented by its base entry — the base attaches it as `fastId`.
38
+ continue
39
+ }
40
+
41
+ const fastId = `${model}-fast`
42
+ const hasFast = present.has(fastId)
43
+ families.push({ fastId: hasFast ? fastId : null, id: model })
44
+ consumed.add(model)
45
+
46
+ if (hasFast) {
47
+ consumed.add(fastId)
48
+ }
49
+ }
50
+
51
+ return families
52
+ }
53
+
54
+ function loadVisible(): Set<string> | null {
55
+ const raw = storedString(STORAGE_KEY)
56
+
57
+ if (!raw) {
58
+ return null
59
+ }
60
+
61
+ try {
62
+ const parsed = JSON.parse(raw)
63
+
64
+ return Array.isArray(parsed) ? new Set(parsed.filter((x): x is string => typeof x === 'string')) : null
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ /** Explicit set of visible `provider::model` keys, or null when the user
71
+ * hasn't customized — in which case the curated default applies. */
72
+ export const $visibleModels = atom<Set<string> | null>(loadVisible())
73
+
74
+ export const $modelVisibilityOpen = atom(false)
75
+
76
+ export function setVisibleModels(keys: Set<string>): void {
77
+ $visibleModels.set(new Set(keys))
78
+ persistString(STORAGE_KEY, JSON.stringify([...keys]))
79
+ }
80
+
81
+ export function setModelVisibilityOpen(open: boolean): void {
82
+ $modelVisibilityOpen.set(open)
83
+ }
84
+
85
+ /** The default-visible key set: the curated top-N per provider. Used both as
86
+ * the dropdown fallback and to seed the Edit Models dialog. */
87
+ export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set<string> {
88
+ const keys = new Set<string>()
89
+
90
+ for (const provider of providers) {
91
+ const families = collapseModelFamilies(provider.models ?? [])
92
+
93
+ for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
94
+ keys.add(modelVisibilityKey(provider.slug, family.id))
95
+ }
96
+ }
97
+
98
+ return keys
99
+ }
100
+
101
+ /** Resolve which keys are currently visible: the user's explicit set when
102
+ * configured, otherwise the curated default for the given providers. */
103
+ export function effectiveVisibleKeys(
104
+ stored: Set<string> | null,
105
+ providers: readonly ModelOptionProvider[]
106
+ ): Set<string> {
107
+ return stored ?? defaultVisibleKeys(providers)
108
+ }
@@ -0,0 +1,192 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { $gateway } from './gateway'
4
+ import {
5
+ dispatchNativeNotification,
6
+ NATIVE_NOTIFICATION_KINDS,
7
+ respondToApprovalAction,
8
+ sendTestNativeNotification,
9
+ setNativeNotifyEnabled,
10
+ setNativeNotifyKind
11
+ } from './native-notifications'
12
+ import { $approvalRequest, setApprovalRequest } from './prompts'
13
+ import { $activeSessionId, setActiveSessionId } from './session'
14
+
15
+ const desktopWindow = window as unknown as { nastechDesktop?: Window['nastechDesktop'] }
16
+ const initialNasTechDesktop = desktopWindow.nastechDesktop
17
+
18
+ const notify = vi.fn().mockResolvedValue(true)
19
+
20
+ function setWindowState({ focused = true, hidden = false }: { focused?: boolean; hidden?: boolean }) {
21
+ Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
22
+ Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => focused })
23
+ }
24
+
25
+ let counter = 0
26
+
27
+ // Unique session id per call dodges the per-(kind,session) throttle so each
28
+ // assertion starts clean.
29
+ function freshSession(): string {
30
+ counter += 1
31
+
32
+ return `session-${counter}`
33
+ }
34
+
35
+ beforeEach(() => {
36
+ notify.mockClear()
37
+ desktopWindow.nastechDesktop = { notify } as unknown as Window['nastechDesktop']
38
+ setNativeNotifyEnabled(true)
39
+
40
+ for (const kind of NATIVE_NOTIFICATION_KINDS) {
41
+ setNativeNotifyKind(kind, true)
42
+ }
43
+
44
+ setActiveSessionId(null)
45
+ setWindowState({ focused: false, hidden: true })
46
+ })
47
+
48
+ afterEach(() => {
49
+ if (initialNasTechDesktop) {
50
+ desktopWindow.nastechDesktop = initialNasTechDesktop
51
+ } else {
52
+ delete desktopWindow.nastechDesktop
53
+ }
54
+ })
55
+
56
+ describe('dispatchNativeNotification focus gating', () => {
57
+ it('fires a completion notification for the active session when the window is hidden', () => {
58
+ const sessionId = freshSession()
59
+ setActiveSessionId(sessionId)
60
+ dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
61
+ expect(notify).toHaveBeenCalledTimes(1)
62
+ })
63
+
64
+ it('fires a completion notification when the window is visible but unfocused (alt-tab)', () => {
65
+ const sessionId = freshSession()
66
+ setActiveSessionId(sessionId)
67
+ setWindowState({ focused: false, hidden: false })
68
+ dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
69
+ expect(notify).toHaveBeenCalledTimes(1)
70
+ })
71
+
72
+ it('suppresses a completion notification when the window is focused', () => {
73
+ const sessionId = freshSession()
74
+ setActiveSessionId(sessionId)
75
+ setWindowState({ focused: true, hidden: false })
76
+ dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
77
+ expect(notify).not.toHaveBeenCalled()
78
+ })
79
+
80
+ it('suppresses a completion notification for a non-active background session (no gateway spam)', () => {
81
+ setActiveSessionId('on-screen')
82
+ dispatchNativeNotification({ kind: 'turnDone', sessionId: 'busy-bot-session', title: 'done' })
83
+ expect(notify).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('fires an attention notification for an off-screen session even when focused', () => {
87
+ setWindowState({ focused: true, hidden: false })
88
+ setActiveSessionId('on-screen')
89
+ dispatchNativeNotification({ kind: 'approval', sessionId: 'background', title: 'approve' })
90
+ expect(notify).toHaveBeenCalledTimes(1)
91
+ })
92
+
93
+ it('suppresses an attention notification for the active session when focused', () => {
94
+ setWindowState({ focused: true, hidden: false })
95
+ setActiveSessionId('on-screen')
96
+ dispatchNativeNotification({ kind: 'approval', sessionId: 'on-screen', title: 'approve' })
97
+ expect(notify).not.toHaveBeenCalled()
98
+ })
99
+ })
100
+
101
+ describe('dispatchNativeNotification preferences', () => {
102
+ it('suppresses everything when the master switch is off', () => {
103
+ setNativeNotifyEnabled(false)
104
+ dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
105
+ dispatchNativeNotification({ kind: 'turnDone', sessionId: freshSession(), title: 'done' })
106
+ expect(notify).not.toHaveBeenCalled()
107
+ })
108
+
109
+ it('suppresses only the disabled kind', () => {
110
+ const sessionId = freshSession()
111
+ setActiveSessionId(sessionId)
112
+ setNativeNotifyKind('turnDone', false)
113
+ dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
114
+ expect(notify).not.toHaveBeenCalled()
115
+
116
+ dispatchNativeNotification({ kind: 'turnError', sessionId, title: 'boom' })
117
+ expect(notify).toHaveBeenCalledTimes(1)
118
+ })
119
+
120
+ it('forwards kind and sessionId to the bridge', () => {
121
+ setActiveSessionId('abc')
122
+ dispatchNativeNotification({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
123
+ expect(notify).toHaveBeenCalledWith(
124
+ expect.objectContaining({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
125
+ )
126
+ })
127
+ })
128
+
129
+ describe('dispatchNativeNotification throttle', () => {
130
+ it('collapses duplicate kind+session within the throttle window', () => {
131
+ const sessionId = freshSession()
132
+ setActiveSessionId(sessionId)
133
+ dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
134
+ dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done again' })
135
+ expect(notify).toHaveBeenCalledTimes(1)
136
+ })
137
+ })
138
+
139
+ describe('sendTestNativeNotification', () => {
140
+ it('fires regardless of focus or active session', () => {
141
+ setWindowState({ focused: true, hidden: false })
142
+ setActiveSessionId('on-screen')
143
+ sendTestNativeNotification('NasTech', 'works')
144
+ expect(notify).toHaveBeenCalledTimes(1)
145
+ })
146
+ })
147
+
148
+ describe('$activeSessionId wiring', () => {
149
+ it('reflects the setter used for gating', () => {
150
+ setActiveSessionId('xyz')
151
+ expect($activeSessionId.get()).toBe('xyz')
152
+ })
153
+ })
154
+
155
+ describe('respondToApprovalAction', () => {
156
+ const request = vi.fn().mockResolvedValue({ resolved: true })
157
+
158
+ beforeEach(() => {
159
+ request.mockClear()
160
+ $gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)
161
+ })
162
+
163
+ afterEach(() => {
164
+ $gateway.set(null)
165
+ })
166
+
167
+ it('approves via approval.respond {choice: "once"} and clears the prompt', async () => {
168
+ setActiveSessionId('bg')
169
+ setApprovalRequest({ command: 'rm -rf /', description: 'dangerous', sessionId: 'bg' })
170
+
171
+ await respondToApprovalAction('bg', 'approve')
172
+
173
+ expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'bg' })
174
+ expect($approvalRequest.get()).toBeNull()
175
+ })
176
+
177
+ it('rejects via approval.respond {choice: "deny"}', async () => {
178
+ await respondToApprovalAction('bg', 'reject')
179
+ expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'bg' })
180
+ })
181
+
182
+ it('ignores unknown action ids', async () => {
183
+ await respondToApprovalAction('bg', 'snooze')
184
+ expect(request).not.toHaveBeenCalled()
185
+ })
186
+
187
+ it('no-ops without a gateway', async () => {
188
+ $gateway.set(null)
189
+ await respondToApprovalAction('bg', 'approve')
190
+ expect(request).not.toHaveBeenCalled()
191
+ })
192
+ })
@@ -0,0 +1,203 @@
1
+ import { atom } from 'nanostores'
2
+
3
+ import { persistString, storedString } from '@/lib/storage'
4
+
5
+ import { $gateway } from './gateway'
6
+ import { clearApprovalRequest } from './prompts'
7
+ import { $activeSessionId } from './session'
8
+
9
+ // Native OS notifications (Electron `Notification`), separate from the in-app
10
+ // toast feed in `notifications.ts`. Each kind toggles independently.
11
+ export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError'
12
+
13
+ export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
14
+ 'approval',
15
+ 'input',
16
+ 'turnDone',
17
+ 'turnError',
18
+ 'backgroundDone'
19
+ ]
20
+
21
+ // Blocking prompts — surface even while focused if they're for another session.
22
+ const ATTENTION_KINDS = new Set<NativeNotificationKind>(['approval', 'input'])
23
+
24
+ export interface NativeNotificationPrefs {
25
+ enabled: boolean
26
+ kinds: Record<NativeNotificationKind, boolean>
27
+ }
28
+
29
+ const STORAGE_KEY = 'nastech:native-notifications'
30
+
31
+ const DEFAULT_PREFS: NativeNotificationPrefs = {
32
+ enabled: true,
33
+ kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
34
+ }
35
+
36
+ function readPrefs(): NativeNotificationPrefs {
37
+ const raw = storedString(STORAGE_KEY)
38
+
39
+ if (!raw) {
40
+ return DEFAULT_PREFS
41
+ }
42
+
43
+ try {
44
+ const parsed = JSON.parse(raw) as Partial<NativeNotificationPrefs>
45
+ const kinds = { ...DEFAULT_PREFS.kinds }
46
+
47
+ for (const kind of NATIVE_NOTIFICATION_KINDS) {
48
+ const value = parsed.kinds?.[kind]
49
+
50
+ if (typeof value === 'boolean') {
51
+ kinds[kind] = value
52
+ }
53
+ }
54
+
55
+ return {
56
+ enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_PREFS.enabled,
57
+ kinds
58
+ }
59
+ } catch {
60
+ return DEFAULT_PREFS
61
+ }
62
+ }
63
+
64
+ export const $nativeNotifyPrefs = atom<NativeNotificationPrefs>(readPrefs())
65
+
66
+ function writePrefs(next: NativeNotificationPrefs) {
67
+ $nativeNotifyPrefs.set(next)
68
+ persistString(STORAGE_KEY, JSON.stringify(next))
69
+ }
70
+
71
+ export function setNativeNotifyEnabled(enabled: boolean) {
72
+ writePrefs({ ...$nativeNotifyPrefs.get(), enabled })
73
+ }
74
+
75
+ export function setNativeNotifyKind(kind: NativeNotificationKind, on: boolean) {
76
+ const prev = $nativeNotifyPrefs.get()
77
+ writePrefs({ ...prev, kinds: { ...prev.kinds, [kind]: on } })
78
+ }
79
+
80
+ // De-dupe replayed events for the same kind+session. Self-evicting: entries
81
+ // older than the window are pruned on every dispatch, so the map can't grow.
82
+ const THROTTLE_MS = 1000
83
+ const lastFiredAt = new Map<string, number>()
84
+
85
+ function throttled(key: string, now: number): boolean {
86
+ for (const [k, at] of lastFiredAt) {
87
+ if (now - at >= THROTTLE_MS) {
88
+ lastFiredAt.delete(k)
89
+ }
90
+ }
91
+
92
+ if (lastFiredAt.has(key)) {
93
+ return true
94
+ }
95
+
96
+ lastFiredAt.set(key, now)
97
+
98
+ return false
99
+ }
100
+
101
+ // "Backgrounded" = the user isn't on NasTech. `document.hidden` only flips when
102
+ // minimized/occluded; an alt-tabbed window is visible-but-unfocused, so we also
103
+ // check `document.hasFocus()`.
104
+ function isBackgrounded(): boolean {
105
+ if (typeof document === 'undefined') {
106
+ return false
107
+ }
108
+
109
+ if (document.hidden) {
110
+ return true
111
+ }
112
+
113
+ return typeof document.hasFocus === 'function' && !document.hasFocus()
114
+ }
115
+
116
+ function shouldFire(kind: NativeNotificationKind, sessionId?: null | string): boolean {
117
+ // Attention kinds break through for an off-screen session even while focused.
118
+ if (ATTENTION_KINDS.has(kind)) {
119
+ return isBackgrounded() || (Boolean(sessionId) && sessionId !== $activeSessionId.get())
120
+ }
121
+
122
+ // Completion kinds: only the active session, only while away — so a busy
123
+ // gateway (messaging, kanban, cron) can't spam a toast per background session.
124
+ return isBackgrounded() && Boolean(sessionId) && sessionId === $activeSessionId.get()
125
+ }
126
+
127
+ export interface NativeNotificationAction {
128
+ id: string
129
+ text: string
130
+ }
131
+
132
+ export interface NativeNotificationInput {
133
+ kind: NativeNotificationKind
134
+ title: string
135
+ body?: string
136
+ sessionId?: null | string
137
+ silent?: boolean
138
+ actions?: NativeNotificationAction[]
139
+ }
140
+
141
+ export function dispatchNativeNotification(input: NativeNotificationInput): void {
142
+ const prefs = $nativeNotifyPrefs.get()
143
+
144
+ if (!prefs.enabled || !prefs.kinds[input.kind]) {
145
+ return
146
+ }
147
+
148
+ if (!shouldFire(input.kind, input.sessionId)) {
149
+ return
150
+ }
151
+
152
+ if (throttled(`${input.kind}:${input.sessionId ?? ''}`, Date.now())) {
153
+ return
154
+ }
155
+
156
+ void window.NASTECHDesktop?.notify({
157
+ actions: input.actions,
158
+ body: input.body,
159
+ kind: input.kind,
160
+ sessionId: input.sessionId ?? undefined,
161
+ silent: input.silent,
162
+ title: input.title
163
+ })
164
+ }
165
+
166
+ // Resolve a pending approval from a notification button, mirroring the in-app
167
+ // Run/Reject bar. Keyed by session id — a background approval has no local guard.
168
+ export async function respondToApprovalAction(sessionId: null | string, actionId: string): Promise<void> {
169
+ const choice = actionId === 'approve' ? 'once' : actionId === 'reject' ? 'deny' : null
170
+
171
+ if (!choice) {
172
+ return
173
+ }
174
+
175
+ const gateway = $gateway.get()
176
+
177
+ if (!gateway) {
178
+ return
179
+ }
180
+
181
+ try {
182
+ await gateway.request('approval.respond', { choice, session_id: sessionId ?? undefined })
183
+ clearApprovalRequest(sessionId)
184
+ } catch {
185
+ // Leave the prompt parked so the user can still resolve it in-app.
186
+ }
187
+ }
188
+
189
+ // Settings "send test" — bypasses gating. Returns whether the OS accepted it so
190
+ // the panel can flag a silent permission failure instead of looking dead.
191
+ export async function sendTestNativeNotification(title: string, body: string): Promise<boolean> {
192
+ const bridge = window.NASTECHDesktop
193
+
194
+ if (!bridge?.notify) {
195
+ return false
196
+ }
197
+
198
+ try {
199
+ return await bridge.notify({ body, kind: 'turnDone', title })
200
+ } catch {
201
+ return false
202
+ }
203
+ }