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,866 @@
1
+ import { atom } from 'nanostores'
2
+
3
+ import {
4
+ cancelOAuthSession,
5
+ getGlobalModelOptions,
6
+ getRecommendedDefaultModel,
7
+ listOAuthProviders,
8
+ pollOAuthSession,
9
+ setEnvVar,
10
+ setModelAssignment,
11
+ startOAuthLogin,
12
+ submitOAuthCode,
13
+ validateProviderCredential
14
+ } from '@/nastech'
15
+ import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
16
+ import { notify, notifyError } from '@/store/notifications'
17
+ import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/nastech'
18
+
19
+ type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
20
+ type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
21
+ type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
22
+
23
+ export type OnboardingMode = 'apikey' | 'oauth'
24
+
25
+ export type OnboardingFlow =
26
+ | { status: 'idle' }
27
+ | { provider: OAuthProvider; status: 'starting' }
28
+ | { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' }
29
+ | { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' }
30
+ // Loopback PKCE (xAI Grok): browser opens, the local backend's 127.0.0.1
31
+ // listener catches the redirect, and we poll until the worker finishes.
32
+ // No code to paste and no user_code to show — just a waiting state.
33
+ | { provider: OAuthProvider; start: LoopbackStart; status: 'awaiting_browser' }
34
+ | { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
35
+ | { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
36
+ | { provider: OAuthProvider; status: 'success' }
37
+ | {
38
+ // After successful credential acquisition, before completing
39
+ // onboarding: show the user which model they're getting and let
40
+ // them change it. providerSlug is the model.options slug for the
41
+ // just-authenticated provider (used to persist the chosen model
42
+ // via /api/model/set). The change-model UI uses the existing
43
+ // ModelPickerDialog, which fetches its own model list from
44
+ // /api/model/options — no need to cache the list here.
45
+ currentModel: string
46
+ label: string
47
+ providerSlug: string
48
+ saving: boolean
49
+ status: 'confirming_model'
50
+ }
51
+ | { message: string; provider?: OAuthProvider; start?: OAuthStartResponse; status: 'error' }
52
+
53
+ export interface DesktopOnboardingState {
54
+ /** null until the first runtime check resolves. Seeded from localStorage so
55
+ * returning users skip the boot overlay entirely instead of flashing it
56
+ * every reload. */
57
+ configured: boolean | null
58
+ flow: OnboardingFlow
59
+ mode: OnboardingMode
60
+ providers: null | OAuthProvider[]
61
+ reason: null | string
62
+ requested: boolean
63
+ /** True when the user explicitly chose "I'll choose a provider later" on the
64
+ * first-run picker. Persisted to localStorage so the blocking overlay never
65
+ * re-nags on subsequent launches — the user can connect a provider any time
66
+ * from Settings → Providers (or the model picker's "Add provider"). Distinct
67
+ * from `configured`: the app still has no usable provider, so chat won't work
68
+ * until one is connected; we just stop forcing the choice up front. */
69
+ firstRunSkipped: boolean
70
+ /** True when the user explicitly opened the provider selector to add /
71
+ * switch providers from an already-configured app (e.g. via the model
72
+ * picker's "Add provider" button). Forces the overlay to show the picker
73
+ * even when configured === true, and adds a close affordance. */
74
+ manual: boolean
75
+ }
76
+
77
+ export interface OnboardingContext {
78
+ onCompleted?: () => void
79
+ requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
80
+ }
81
+
82
+ const CONFIGURED_CACHE_KEY = 'NASTECH-desktop-onboarded-v1'
83
+ const SKIP_CACHE_KEY = 'NASTECH-onboarding-skipped-v1'
84
+ const POLL_MS = 2000
85
+ const COPY_FLASH_MS = 1500
86
+ export const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
87
+ export const DEFAULT_MANUAL_ONBOARDING_REASON = 'Add or switch inference provider.'
88
+
89
+ function readCachedConfigured(): boolean | null {
90
+ if (typeof window === 'undefined') {
91
+ return null
92
+ }
93
+
94
+ try {
95
+ return window.localStorage.getItem(CONFIGURED_CACHE_KEY) === '1' ? true : null
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ function writeCachedConfigured(value: boolean) {
102
+ if (typeof window === 'undefined') {
103
+ return
104
+ }
105
+
106
+ try {
107
+ if (value) {
108
+ window.localStorage.setItem(CONFIGURED_CACHE_KEY, '1')
109
+ } else {
110
+ window.localStorage.removeItem(CONFIGURED_CACHE_KEY)
111
+ }
112
+ } catch {
113
+ // localStorage unavailable — degrade silently.
114
+ }
115
+ }
116
+
117
+ function readCachedSkipped(): boolean {
118
+ if (typeof window === 'undefined') {
119
+ return false
120
+ }
121
+
122
+ try {
123
+ return window.localStorage.getItem(SKIP_CACHE_KEY) === '1'
124
+ } catch {
125
+ return false
126
+ }
127
+ }
128
+
129
+ function writeCachedSkipped(value: boolean) {
130
+ if (typeof window === 'undefined') {
131
+ return
132
+ }
133
+
134
+ try {
135
+ if (value) {
136
+ window.localStorage.setItem(SKIP_CACHE_KEY, '1')
137
+ } else {
138
+ window.localStorage.removeItem(SKIP_CACHE_KEY)
139
+ }
140
+ } catch {
141
+ // localStorage unavailable — degrade silently.
142
+ }
143
+ }
144
+
145
+ const INITIAL: DesktopOnboardingState = {
146
+ configured: readCachedConfigured(),
147
+ flow: { status: 'idle' },
148
+ mode: 'oauth',
149
+ providers: null,
150
+ reason: null,
151
+ requested: false,
152
+ firstRunSkipped: readCachedSkipped(),
153
+ manual: false
154
+ }
155
+
156
+ export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
157
+
158
+ let pollTimer: number | null = null
159
+ let providersRefreshPromise: null | Promise<void> = null
160
+
161
+ const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e))
162
+
163
+ const patch = (update: Partial<DesktopOnboardingState>) =>
164
+ $desktopOnboarding.set({ ...$desktopOnboarding.get(), ...update })
165
+
166
+ const setFlow = (flow: OnboardingFlow) =>
167
+ patch(flow.status === 'idle' ? { flow } : { flow, reason: null })
168
+
169
+ const sessionIdFor = (flow: OnboardingFlow) => ('start' in flow && flow.start ? flow.start.session_id : undefined)
170
+
171
+ function clearPoll() {
172
+ if (pollTimer !== null) {
173
+ window.clearInterval(pollTimer)
174
+ pollTimer = null
175
+ }
176
+ }
177
+
178
+ async function checkRuntime(ctx: OnboardingContext): Promise<RuntimeReadinessResult> {
179
+ return evaluateRuntimeReadiness(ctx.requestGateway, {
180
+ defaultReason: DEFAULT_ONBOARDING_REASON,
181
+ unknownReady: false
182
+ })
183
+ }
184
+
185
+ function notifyReady(provider: string) {
186
+ notify({ kind: 'success', title: 'NasTech is ready', message: `${provider} connected.` })
187
+ }
188
+
189
+ // Human-friendly labels for tools auto-routed through the NasTech Tool Gateway,
190
+ // mirroring nastech_cli/nastech_subscription._GATEWAY_TOOL_LABELS so the GUI and
191
+ // CLI describe the same thing.
192
+ const GATEWAY_TOOL_LABELS: Record<string, string> = {
193
+ browser: 'browser automation',
194
+ image_gen: 'image generation',
195
+ tts: 'text-to-speech',
196
+ video_gen: 'video generation',
197
+ web: 'web search & extract'
198
+ }
199
+
200
+ // When switching to NasTech Portal auto-routes unconfigured tools through the Tool
201
+ // Gateway, tell the user which ones — same information the CLI prints. Silent
202
+ // when nothing changed (subscriber already configured, has own keys, etc.).
203
+ function notifyGatewayTools(tools: string[] | undefined) {
204
+ if (!tools || tools.length === 0) {
205
+ return
206
+ }
207
+
208
+ const labels = tools.map(t => GATEWAY_TOOL_LABELS[t] ?? t)
209
+ const list = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`
210
+
211
+ notify({
212
+ durationMs: 8000,
213
+ kind: 'info',
214
+ message: `${list} now run through your NasTech Portal subscription — no separate API keys needed.`,
215
+ title: 'Tool Gateway enabled'
216
+ })
217
+ }
218
+
219
+ // After credentials are persisted, ask the backend which provider+models
220
+ // are now authenticated. Pick the first curated model for the matching
221
+ // provider as a sensible default, persist it via /api/model/set, and
222
+ // transition to the model-confirmation step. If anything goes wrong
223
+ // fetching options (no providers returned, network error), the caller
224
+ // falls through to completing onboarding without showing the confirm
225
+ // card — the user gets the undefined-model auto-selection behaviour
226
+ // we had before, which works but is surprising. The confirm step is
227
+ // opportunistic polish, not a hard requirement for onboarding.
228
+ async function fetchProviderDefaultModel(
229
+ preferredSlugs: string[]
230
+ ): Promise<null | { providerSlug: string; defaultModel: string }> {
231
+ let options
232
+
233
+ try {
234
+ options = await getGlobalModelOptions()
235
+ } catch {
236
+ return null
237
+ }
238
+
239
+ const providers = options?.providers ?? []
240
+
241
+ if (providers.length === 0) {
242
+ return null
243
+ }
244
+
245
+ // Try each preferred slug (lowercased), fall back to the first provider
246
+ // returned (model.options orders by recency / authenticated state, so
247
+ // the just-authenticated provider is usually first anyway).
248
+ const lower = preferredSlugs.map(s => s.toLowerCase())
249
+
250
+ const matched =
251
+ providers.find((p: ModelOptionProvider) => lower.includes(String(p.slug).toLowerCase())) ?? providers[0]
252
+
253
+ const models = matched.models ?? []
254
+
255
+ if (models.length === 0) {
256
+ return null
257
+ }
258
+
259
+ // Prefer the backend's recommended default — it mirrors the curation
260
+ // `NASTECH model` does (for NasTech Portal it honors the user's free/paid tier, so a
261
+ // free user gets a free model rather than a paid default like opus). Fall
262
+ // back to the first curated model if the endpoint can't resolve one.
263
+ let defaultModel = String(models[0])
264
+
265
+ try {
266
+ const recommended = await getRecommendedDefaultModel(String(matched.slug))
267
+
268
+ if (recommended.model && models.map(String).includes(recommended.model)) {
269
+ defaultModel = recommended.model
270
+ } else if (recommended.model) {
271
+ // Recommended model isn't in the curated options list (e.g. a Portal
272
+ // free-recommendation the picker list didn't include); trust it anyway.
273
+ defaultModel = recommended.model
274
+ }
275
+ } catch {
276
+ // Endpoint unavailable — keep models[0]. Non-fatal: the confirm card still
277
+ // shows and the user can change it.
278
+ }
279
+
280
+ return {
281
+ providerSlug: String(matched.slug),
282
+ defaultModel
283
+ }
284
+ }
285
+
286
+ // After OAuth/API-key success: reload the backend env, verify runtime,
287
+ // then either show the model-confirm step or fall straight through to
288
+ // completion if we can't determine a default.
289
+ //
290
+ // onFail receives the runtime-readiness `reason` from checkRuntime so
291
+ // the caller can fold it into a user-facing error — same contract as
292
+ // reloadAndConnect used to have (which this replaces).
293
+ async function completeWithModelConfirm(
294
+ ctx: OnboardingContext,
295
+ providerLabel: string,
296
+ preferredSlugs: string[],
297
+ onFail: (reason: null | string) => void,
298
+ // When true, a failing runtime check no longer blocks progression — the
299
+ // user is allowed through onboarding regardless. Used by the API-key path,
300
+ // where we intentionally don't validate the key (it blocked too many users).
301
+ ignoreRuntimeGate = false
302
+ ) {
303
+ await ctx.requestGateway('reload.env').catch(() => undefined)
304
+ const runtime = await checkRuntime(ctx)
305
+
306
+ if (!runtime.ready && !ignoreRuntimeGate) {
307
+ onFail(runtime.reason)
308
+
309
+ return
310
+ }
311
+
312
+ const defaults = await fetchProviderDefaultModel(preferredSlugs)
313
+
314
+ if (!defaults) {
315
+ // Couldn't get a sensible default — proceed without confirm step.
316
+ notifyReady(providerLabel)
317
+ completeDesktopOnboarding()
318
+ ctx.onCompleted?.()
319
+
320
+ return
321
+ }
322
+
323
+ // Persist the default model BEFORE showing the confirm card so that:
324
+ // (1) "current default: X" shown in the UI is what's actually written
325
+ // to config — no lying.
326
+ // (2) If the user clicks "Start chatting" without changing anything,
327
+ // no extra write is needed.
328
+ // (3) If they bail out (e.g., refresh the page), they still end up
329
+ // with a working config, not an empty-model fallback.
330
+ try {
331
+ const res = await setModelAssignment({
332
+ scope: 'main',
333
+ provider: defaults.providerSlug,
334
+ model: defaults.defaultModel
335
+ })
336
+
337
+ notifyGatewayTools(res.gateway_tools)
338
+ } catch {
339
+ // Persistence failed — still show the confirm card so the user can
340
+ // pick something explicitly. The backend will pick its own default
341
+ // at chat time if we end up never persisting.
342
+ }
343
+
344
+ setFlow({
345
+ status: 'confirming_model',
346
+ providerSlug: defaults.providerSlug,
347
+ currentModel: defaults.defaultModel,
348
+ label: providerLabel,
349
+ saving: false
350
+ })
351
+ }
352
+
353
+ function providerResolutionFailure(reason: null | string) {
354
+ const detail = reason?.trim()
355
+
356
+ return detail
357
+ ? `Connected, but NasTech still cannot resolve a usable provider. ${detail}`
358
+ : 'Connected, but NasTech still cannot resolve a usable provider.'
359
+ }
360
+
361
+ async function refreshProviders() {
362
+ if (providersRefreshPromise) {
363
+ await providersRefreshPromise
364
+
365
+ return
366
+ }
367
+
368
+ providersRefreshPromise = (async () => {
369
+ try {
370
+ const { providers } = await listOAuthProviders()
371
+ patch({ mode: providers.length > 0 ? 'oauth' : 'apikey', providers })
372
+ } catch {
373
+ patch({ mode: 'apikey', providers: [] })
374
+ } finally {
375
+ providersRefreshPromise = null
376
+ }
377
+ })()
378
+
379
+ await providersRefreshPromise
380
+ }
381
+
382
+ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
383
+ patch({ reason: reason.trim() || DEFAULT_ONBOARDING_REASON, requested: true })
384
+ }
385
+
386
+ // Open the onboarding provider selector on demand from an already-configured
387
+ // app — e.g. the model picker's "Add provider" button. Reuses the entire
388
+ // onboarding flow (OAuth rows, API-key form, model-confirm) instead of
389
+ // duplicating provider UI. Sets manual=true so the overlay shows the picker
390
+ // even though configured===true, and refreshes the provider list.
391
+ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONBOARDING_REASON) {
392
+ patch({
393
+ manual: true,
394
+ requested: true,
395
+ // `null` opts out of the prompt banner entirely (e.g. when the user already
396
+ // picked a specific provider and we auto-start its sign-in).
397
+ reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
398
+ flow: { status: 'idle' }
399
+ })
400
+ void refreshProviders()
401
+ }
402
+
403
+ // One-shot hand-off used when the dedicated Providers settings page launches a
404
+ // specific provider's sign-in: we open the manual onboarding overlay AND
405
+ // remember which provider to start, so the overlay drives that exact OAuth
406
+ // flow instead of re-showing the picker the user just clicked through.
407
+ // Module-level (not store state) because it's consumed immediately on the next
408
+ // overlay render and never needs to persist or re-render anything itself.
409
+ let pendingProviderOAuthId: null | string = null
410
+
411
+ export function startManualProviderOAuth(providerId: string, reason: null | string = null) {
412
+ pendingProviderOAuthId = providerId
413
+ startManualOnboarding(reason)
414
+ }
415
+
416
+ // Read the pending provider id without clearing it. The overlay only clears it
417
+ // (via clearPendingProviderOAuth) once it has actually launched that provider,
418
+ // so a transient empty/failed provider fetch doesn't drop the hand-off and the
419
+ // deep-link can still auto-start after the list loads.
420
+ export function peekPendingProviderOAuth(): null | string {
421
+ return pendingProviderOAuthId
422
+ }
423
+
424
+ export function clearPendingProviderOAuth() {
425
+ pendingProviderOAuthId = null
426
+ }
427
+
428
+ // Dismiss a manually-opened provider selector without touching the existing
429
+ // (working) configuration. Only valid in the manual path — the unconfigured
430
+ // first-run flow has no close affordance because the app can't run yet.
431
+ export function closeManualOnboarding() {
432
+ pendingProviderOAuthId = null
433
+
434
+ patch({ manual: false, requested: false, flow: { status: 'idle' } })
435
+ }
436
+
437
+ export function completeDesktopOnboarding() {
438
+ clearPoll()
439
+ writeCachedConfigured(true)
440
+ // A real provider is now connected, so any earlier "choose later" skip is
441
+ // moot — clear it so the flag never lingers in a configured install.
442
+ writeCachedSkipped(false)
443
+ $desktopOnboarding.set({
444
+ configured: true,
445
+ flow: { status: 'idle' },
446
+ mode: 'oauth',
447
+ providers: null,
448
+ reason: null,
449
+ requested: false,
450
+ firstRunSkipped: false,
451
+ manual: false
452
+ })
453
+ }
454
+
455
+ // "I'll choose a provider later" on the first-run picker. Persists the skip so
456
+ // the blocking overlay never re-nags on future launches, and dismisses it now
457
+ // so the user lands in the app. Chat won't work until a provider is connected
458
+ // (from Settings → Providers or the model picker's "Add provider") — this only
459
+ // stops forcing the choice up front. Distinct from completeDesktopOnboarding,
460
+ // which marks the app actually configured.
461
+ export function dismissFirstRunOnboarding() {
462
+ clearPoll()
463
+ writeCachedSkipped(true)
464
+ patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
465
+ }
466
+
467
+ export function setOnboardingMode(mode: OnboardingMode) {
468
+ patch({ mode })
469
+ }
470
+
471
+ export async function refreshOnboarding(ctx: OnboardingContext) {
472
+ // Manual mode (user opened the selector from a working app): never
473
+ // auto-dismiss on runtime-ready — the whole point is to let them add /
474
+ // switch a provider while already configured. Just ensure the provider
475
+ // list is loaded and show the picker.
476
+ if ($desktopOnboarding.get().manual) {
477
+ await refreshProviders()
478
+
479
+ return false
480
+ }
481
+
482
+ const runtime = await checkRuntime(ctx)
483
+
484
+ if (runtime.ready) {
485
+ completeDesktopOnboarding()
486
+ ctx.onCompleted?.()
487
+
488
+ return true
489
+ }
490
+
491
+ const state = $desktopOnboarding.get()
492
+ const reason = runtime.reason || state.reason || DEFAULT_ONBOARDING_REASON
493
+
494
+ writeCachedConfigured(false)
495
+ patch({ configured: false, reason })
496
+
497
+ if (state.providers !== null && !state.requested) {
498
+ return false
499
+ }
500
+
501
+ await refreshProviders()
502
+
503
+ return false
504
+ }
505
+
506
+ // Open a sign-in URL via the desktop bridge, falling back to window.open
507
+ // when the bridge isn't present (e.g. the web dashboard / dev preview) so
508
+ // the flow never silently stalls in a waiting state. Mirrors the pattern in
509
+ // apps/desktop/src/app/artifacts/index.tsx.
510
+ async function openSignInUrl(url: string) {
511
+ if (window.NASTECHDesktop?.openExternal) {
512
+ try {
513
+ await window.NASTECHDesktop.openExternal(url)
514
+
515
+ return
516
+ } catch {
517
+ // Bridge present but failed (no OS handler, user denied, etc.). Fall
518
+ // through to window.open so the sign-in URL still opens and the flow
519
+ // doesn't strand a pending OAuth session in a waiting state.
520
+ }
521
+ }
522
+
523
+ window.open(url, '_blank', 'noopener,noreferrer')
524
+ }
525
+
526
+ export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) {
527
+ clearPoll()
528
+
529
+ if (provider.flow === 'external') {
530
+ setFlow({ status: 'external_pending', provider, copied: false })
531
+
532
+ return
533
+ }
534
+
535
+ setFlow({ status: 'starting', provider })
536
+
537
+ try {
538
+ const start = await startOAuthLogin(provider.id)
539
+ const browserUrl = start.flow === 'device_code' ? start.verification_url : start.auth_url
540
+ await openSignInUrl(browserUrl)
541
+
542
+ if (start.flow === 'pkce') {
543
+ setFlow({ status: 'awaiting_user', provider, start, code: '' })
544
+
545
+ return
546
+ }
547
+
548
+ if (start.flow === 'loopback') {
549
+ // No code to paste: the redirect lands on the backend's loopback
550
+ // listener. Just wait and poll the session until the worker finishes.
551
+ setFlow({ status: 'awaiting_browser', provider, start })
552
+ pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
553
+
554
+ return
555
+ }
556
+
557
+ setFlow({ status: 'polling', provider, start, copied: false })
558
+ pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
559
+ } catch (error) {
560
+ setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
561
+ }
562
+ }
563
+
564
+ // Poll a session-backed flow (device_code or loopback) until it resolves.
565
+ // Both shapes only need the session_id to poll; the start is threaded
566
+ // through to the error flow so the user can retry from the same context.
567
+ async function pollSession(provider: OAuthProvider, start: DeviceStart | LoopbackStart, ctx: OnboardingContext) {
568
+ try {
569
+ const { error_message, status } = await pollOAuthSession(provider.id, start.session_id)
570
+
571
+ if (status === 'approved') {
572
+ clearPoll()
573
+ setFlow({ status: 'success', provider })
574
+ await completeWithModelConfirm(ctx, provider.name, [provider.id], reason =>
575
+ setFlow({
576
+ status: 'error',
577
+ provider,
578
+ message: providerResolutionFailure(reason)
579
+ })
580
+ )
581
+ } else if (status !== 'pending') {
582
+ clearPoll()
583
+ setFlow({ status: 'error', provider, start, message: error_message || `Sign-in ${status}.` })
584
+ }
585
+ } catch (error) {
586
+ clearPoll()
587
+ setFlow({ status: 'error', provider, start, message: `Polling failed: ${errMessage(error)}` })
588
+ }
589
+ }
590
+
591
+ export function setOnboardingCode(code: string) {
592
+ const { flow } = $desktopOnboarding.get()
593
+
594
+ if (flow.status === 'awaiting_user') {
595
+ setFlow({ ...flow, code })
596
+ }
597
+ }
598
+
599
+ export async function submitOnboardingCode(ctx: OnboardingContext) {
600
+ const { flow } = $desktopOnboarding.get()
601
+
602
+ if (flow.status !== 'awaiting_user' || !flow.code.trim()) {
603
+ return
604
+ }
605
+
606
+ const { provider, start, code } = flow
607
+ setFlow({ status: 'submitting', provider, start })
608
+
609
+ try {
610
+ const resp = await submitOAuthCode(provider.id, start.session_id, code.trim())
611
+
612
+ if (resp.ok && resp.status === 'approved') {
613
+ setFlow({ status: 'success', provider })
614
+ await completeWithModelConfirm(ctx, provider.name, [provider.id], reason =>
615
+ setFlow({
616
+ status: 'error',
617
+ provider,
618
+ message: providerResolutionFailure(reason)
619
+ })
620
+ )
621
+ } else {
622
+ setFlow({ status: 'error', provider, start, message: resp.message || 'Token exchange failed.' })
623
+ }
624
+ } catch (error) {
625
+ setFlow({ status: 'error', provider, start, message: errMessage(error) })
626
+ }
627
+ }
628
+
629
+ export function cancelOnboardingFlow() {
630
+ clearPoll()
631
+ const sessionId = sessionIdFor($desktopOnboarding.get().flow)
632
+
633
+ if (sessionId) {
634
+ cancelOAuthSession(sessionId).catch(() => undefined)
635
+ }
636
+
637
+ setFlow({ status: 'idle' })
638
+ }
639
+
640
+ async function copyAndFlash(text: string, predicate: (flow: OnboardingFlow) => boolean) {
641
+ try {
642
+ await navigator.clipboard.writeText(text)
643
+ } catch {
644
+ return
645
+ }
646
+
647
+ const { flow } = $desktopOnboarding.get()
648
+
649
+ if (!predicate(flow) || !('copied' in flow)) {
650
+ return
651
+ }
652
+
653
+ setFlow({ ...flow, copied: true })
654
+ window.setTimeout(() => {
655
+ const current = $desktopOnboarding.get().flow
656
+
657
+ if (predicate(current) && 'copied' in current) {
658
+ setFlow({ ...current, copied: false })
659
+ }
660
+ }, COPY_FLASH_MS)
661
+ }
662
+
663
+ export async function copyDeviceCode() {
664
+ const { flow } = $desktopOnboarding.get()
665
+
666
+ if (flow.status !== 'polling') {
667
+ return
668
+ }
669
+
670
+ const sid = flow.start.session_id
671
+ await copyAndFlash(flow.start.user_code, f => f.status === 'polling' && f.start.session_id === sid)
672
+ }
673
+
674
+ export async function copyExternalCommand() {
675
+ const { flow } = $desktopOnboarding.get()
676
+
677
+ if (flow.status !== 'external_pending') {
678
+ return
679
+ }
680
+
681
+ const id = flow.provider.id
682
+ await copyAndFlash(flow.provider.cli_command, f => f.status === 'external_pending' && f.provider.id === id)
683
+ }
684
+
685
+ export async function recheckExternalSignin(ctx: OnboardingContext) {
686
+ const { flow } = $desktopOnboarding.get()
687
+
688
+ if (flow.status !== 'external_pending') {
689
+ return
690
+ }
691
+
692
+ const { provider } = flow
693
+ await completeWithModelConfirm(ctx, provider.name, [provider.id], reason =>
694
+ setFlow({
695
+ status: 'error',
696
+ provider,
697
+ message:
698
+ reason?.trim() ||
699
+ `NasTech still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
700
+ })
701
+ )
702
+ }
703
+
704
+ export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) {
705
+ const trimmed = value.trim()
706
+
707
+ if (!trimmed) {
708
+ return { ok: false, message: 'Enter a value first.' }
709
+ }
710
+
711
+ // The "Local / custom endpoint" option carries a base URL, not an API key.
712
+ // It must be wired into config (provider=custom + base_url + model), not
713
+ // dropped into .env — runtime resolution ignores OPENAI_BASE_URL.
714
+ if (envKey === 'OPENAI_BASE_URL') {
715
+ return saveOnboardingLocalEndpoint(trimmed, ctx)
716
+ }
717
+
718
+ // No key validation here on purpose: we previously live-probed the key and
719
+ // hard-blocked on a runtime check after saving, which rejected too many
720
+ // legitimate users (corporate proxies, regional blocks, flaky/rate-limited
721
+ // provider probes, self-hosted endpoints). We now save the value as-is and
722
+ // let the user proceed; an actually-bad key surfaces later at chat time.
723
+ try {
724
+ await setEnvVar(envKey, trimmed)
725
+ // For API-key flows we don't have a definitive provider id (the
726
+ // user picked which API key they're entering, but the corresponding
727
+ // backend slug — e.g. OPENROUTER_API_KEY → "openrouter" — is the
728
+ // env-key prefix stripped). Pass a couple of likely candidates;
729
+ // fetchProviderDefaultModel falls back to the first authenticated
730
+ // provider returned by /api/model/options if none match.
731
+ const slugCandidates = [envKey.replace(/_API_KEY$/, '').toLowerCase(), label.toLowerCase()]
732
+ // ignoreRuntimeGate=true: never block onboarding on the runtime check.
733
+ await completeWithModelConfirm(ctx, label, slugCandidates, () => undefined, true)
734
+
735
+ return { ok: true }
736
+ } catch (error) {
737
+ notifyError(error, `Could not save ${label}`)
738
+
739
+ return { ok: false, message: errMessage(error) }
740
+ }
741
+ }
742
+
743
+ // Configure a local / self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp,
744
+ // Ollama, …). Unlike API-key providers, a local endpoint is defined by its URL
745
+ // and usually needs NO key. The runtime resolver reads model.base_url from
746
+ // config (it ignores the OPENAI_BASE_URL env var), so we persist
747
+ // provider=custom + base_url + model via /api/model/set rather than dropping an
748
+ // env var that resolution never consults.
749
+ //
750
+ // The model is auto-discovered from the endpoint's /v1/models (surfaced by the
751
+ // validate probe) so the user only has to paste a URL — no extra UI field.
752
+ //
753
+ // We deliberately don't route through completeWithModelConfirm: that path
754
+ // re-assigns the model from /api/model/options WITHOUT a base_url, which would
755
+ // wipe the base_url we just wrote. We have a concrete model already, so we
756
+ // verify the runtime directly and finish.
757
+ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: OnboardingContext) {
758
+ const url = baseUrl.trim()
759
+
760
+ if (!url) {
761
+ return { ok: false, message: 'Enter the endpoint URL first.' }
762
+ }
763
+
764
+ // Probe connectivity + discover the served models. Any HTTP response proves
765
+ // the endpoint is up; an unreachable probe hard-blocks because we can't
766
+ // resolve a model to route to.
767
+ let model = ''
768
+
769
+ try {
770
+ const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
771
+
772
+ if (!probe.ok && probe.reachable) {
773
+ return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
774
+ }
775
+
776
+ if (!probe.reachable) {
777
+ return { ok: false, message: probe.message || `Could not reach ${url}.` }
778
+ }
779
+
780
+ model = (probe.models?.[0] ?? '').trim()
781
+ } catch {
782
+ return { ok: false, message: `Could not reach ${url}.` }
783
+ }
784
+
785
+ if (!model) {
786
+ return {
787
+ ok: false,
788
+ message: `Connected to ${url}, but it advertised no models at /v1/models. Start a model on that endpoint and try again.`
789
+ }
790
+ }
791
+
792
+ try {
793
+ await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url })
794
+ await ctx.requestGateway('reload.env').catch(() => undefined)
795
+
796
+ const runtime = await checkRuntime(ctx)
797
+
798
+ if (!runtime.ready) {
799
+ const detail = (runtime.reason ?? '').trim()
800
+
801
+ return { ok: false, message: detail || `Saved, but NasTech still cannot reach ${url}.` }
802
+ }
803
+
804
+ notifyReady('Local / custom endpoint')
805
+ completeDesktopOnboarding()
806
+ ctx.onCompleted?.()
807
+
808
+ return { ok: true }
809
+ } catch (error) {
810
+ notifyError(error, 'Could not save local endpoint')
811
+
812
+ return { ok: false, message: errMessage(error) }
813
+ }
814
+ }
815
+
816
+ // User picked a different model from the dropdown on the confirm card.
817
+ // Persists immediately so the displayed value is always what's on disk.
818
+ export async function setOnboardingModel(model: string) {
819
+ const { flow } = $desktopOnboarding.get()
820
+
821
+ if (flow.status !== 'confirming_model') {
822
+ return
823
+ }
824
+
825
+ // Optimistic update so the dropdown feels instant; revert on failure.
826
+ const previous = flow.currentModel
827
+ setFlow({ ...flow, currentModel: model, saving: true })
828
+
829
+ try {
830
+ await setModelAssignment({
831
+ scope: 'main',
832
+ provider: flow.providerSlug,
833
+ model
834
+ })
835
+ const current = $desktopOnboarding.get().flow
836
+
837
+ if (current.status === 'confirming_model') {
838
+ setFlow({ ...current, currentModel: model, saving: false })
839
+ }
840
+ } catch (error) {
841
+ notifyError(error, 'Could not change model')
842
+ const current = $desktopOnboarding.get().flow
843
+
844
+ if (current.status === 'confirming_model') {
845
+ setFlow({ ...current, currentModel: previous, saving: false })
846
+ }
847
+ }
848
+ }
849
+
850
+ // User clicked "Start chatting" on the confirm card. Finalizes onboarding
851
+ // — the model was already persisted by completeWithModelConfirm (or by
852
+ // setOnboardingModel if they changed it), so all that's left is to mark
853
+ // onboarding done and unblock the rest of the app.
854
+ export function confirmOnboardingModel(ctx: OnboardingContext) {
855
+ const { flow } = $desktopOnboarding.get()
856
+
857
+ if (flow.status !== 'confirming_model') {
858
+ return
859
+ }
860
+
861
+ // No success toast here: the confirm-model screen already showed "<provider>
862
+ // connected." notifyReady is reserved for completion paths that SKIP this
863
+ // screen (no-default fallthrough, local endpoint) so feedback isn't lost.
864
+ completeDesktopOnboarding()
865
+ ctx.onCompleted?.()
866
+ }