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,1286 @@
1
+ import { useStore } from '@nanostores/react'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import { ModelPickerDialog } from '@/components/model-picker'
6
+ import { Button } from '@/components/ui/button'
7
+ import { Codicon } from '@/components/ui/codicon'
8
+ import { ErrorIcon } from '@/components/ui/error-state'
9
+ import { Input } from '@/components/ui/input'
10
+ import { Loader } from '@/components/ui/loader'
11
+ import { getGlobalModelOptions } from '@/nastech'
12
+ import { useI18n } from '@/i18n'
13
+ import {
14
+ Check,
15
+ ChevronDown,
16
+ ChevronLeft,
17
+ ChevronRight,
18
+ ExternalLink,
19
+ KeyRound,
20
+ Loader2,
21
+ Terminal
22
+ } from '@/lib/icons'
23
+ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
24
+ import { cn } from '@/lib/utils'
25
+ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
26
+ import {
27
+ $desktopOnboarding,
28
+ cancelOnboardingFlow,
29
+ clearPendingProviderOAuth,
30
+ closeManualOnboarding,
31
+ confirmOnboardingModel,
32
+ copyDeviceCode,
33
+ copyExternalCommand,
34
+ DEFAULT_MANUAL_ONBOARDING_REASON,
35
+ DEFAULT_ONBOARDING_REASON,
36
+ dismissFirstRunOnboarding,
37
+ type OnboardingContext,
38
+ type OnboardingFlow,
39
+ peekPendingProviderOAuth,
40
+ recheckExternalSignin,
41
+ refreshOnboarding,
42
+ saveOnboardingApiKey,
43
+ setOnboardingCode,
44
+ setOnboardingMode,
45
+ setOnboardingModel,
46
+ startProviderOAuth,
47
+ submitOnboardingCode
48
+ } from '@/store/onboarding'
49
+ import type { ModelOptionProvider, OAuthProvider } from '@/types/nastech'
50
+
51
+ interface DesktopOnboardingOverlayProps {
52
+ enabled: boolean
53
+ onCompleted?: () => void
54
+ requestGateway: OnboardingContext['requestGateway']
55
+ }
56
+
57
+ export interface ApiKeyOption {
58
+ description?: string
59
+ docsUrl: string
60
+ envKey: string
61
+ id: string
62
+ name: string
63
+ placeholder?: string
64
+ short?: string
65
+ }
66
+
67
+ const API_KEY_OPTIONS: ApiKeyOption[] = [
68
+ {
69
+ id: 'openrouter',
70
+ name: 'OpenRouter',
71
+ envKey: 'OPENROUTER_API_KEY',
72
+ docsUrl: 'https://openrouter.ai/keys'
73
+ },
74
+ {
75
+ id: 'openai',
76
+ name: 'OpenAI',
77
+ envKey: 'OPENAI_API_KEY',
78
+ docsUrl: 'https://platform.openai.com/api-keys'
79
+ },
80
+ {
81
+ id: 'gemini',
82
+ name: 'Google Gemini',
83
+ envKey: 'GEMINI_API_KEY',
84
+ docsUrl: 'https://aistudio.google.com/app/apikey'
85
+ },
86
+ {
87
+ id: 'xai',
88
+ name: 'xAI Grok',
89
+ envKey: 'XAI_API_KEY',
90
+ docsUrl: 'https://console.x.ai/'
91
+ },
92
+ {
93
+ id: 'local',
94
+ name: 'Local / custom endpoint',
95
+ envKey: 'OPENAI_BASE_URL',
96
+ docsUrl: 'https://github.com/nastech-ai/NasTech-Agent#bring-your-own-endpoint',
97
+ placeholder: 'http://127.0.0.1:8000/v1'
98
+ }
99
+ ]
100
+
101
+ // Build the FULL API-key provider catalog from the backend model options so the
102
+ // onboarding / Providers key form lists every `api_key` provider `nastech model`
103
+ // knows about — not just the hand-curated five. Curated entries keep their
104
+ // richer copy + placeholders and float to the top (recommended defaults); every
105
+ // other api_key provider is appended with a generic "paste {KEY}" affordance.
106
+ // OAuth / external providers are intentionally excluded here — they go through
107
+ // the OAuth picker / sign-in flow, not a pasted key.
108
+ function useApiKeyCatalog(): ApiKeyOption[] {
109
+ const [rows, setRows] = useState<ModelOptionProvider[]>([])
110
+
111
+ useEffect(() => {
112
+ let cancelled = false
113
+
114
+ // Best-effort — on failure the curated defaults still render. Wrapped in
115
+ // Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in
116
+ // tests) is funneled into the same .catch instead of escaping.
117
+ void Promise.resolve()
118
+ .then(() => getGlobalModelOptions())
119
+ .then(res => {
120
+ if (!cancelled) {
121
+ setRows(res.providers ?? [])
122
+ }
123
+ })
124
+ .catch(() => {
125
+ // Ignore — fall back to the curated API_KEY_OPTIONS only.
126
+ })
127
+
128
+ return () => {
129
+ cancelled = true
130
+ }
131
+ }, [])
132
+
133
+ return useMemo(() => {
134
+ const curatedByEnv = new Map(API_KEY_OPTIONS.map(o => [o.envKey, o]))
135
+ const derived: ApiKeyOption[] = []
136
+ const seenEnv = new Set<string>(API_KEY_OPTIONS.map(o => o.envKey))
137
+
138
+ for (const row of rows) {
139
+ // Only api_key providers can be activated with a pasted key. Skip OAuth /
140
+ // external / managed flows and anything missing an env var to write to.
141
+ if (row.auth_type && row.auth_type !== 'api_key') {
142
+ continue
143
+ }
144
+
145
+ const envKey = row.key_env
146
+
147
+ if (!envKey || seenEnv.has(envKey)) {
148
+ continue
149
+ }
150
+
151
+ seenEnv.add(envKey)
152
+ derived.push({
153
+ id: row.slug,
154
+ name: row.name,
155
+ envKey,
156
+ description: `Direct API access to ${row.name}.`,
157
+ docsUrl: ''
158
+ })
159
+ }
160
+
161
+ // Curated first (recommended order), then the rest alphabetically so the
162
+ // long tail is scannable.
163
+ derived.sort((a, b) => a.name.localeCompare(b.name))
164
+
165
+ return [...API_KEY_OPTIONS.filter(o => curatedByEnv.has(o.envKey)), ...derived]
166
+ }, [rows])
167
+ }
168
+
169
+ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
170
+ nastech: { order: 0, title: 'NasTech Portal' },
171
+ 'openai-codex': { order: 1, title: 'OpenAI OAuth (ChatGPT)' },
172
+ 'minimax-oauth': { order: 2, title: 'MiniMax' },
173
+ 'qwen-oauth': { order: 3, title: 'Qwen Code' },
174
+ 'xai-oauth': { order: 4, title: 'xAI Grok' },
175
+ // Both Anthropic entries sit at the bottom: the API-key path first, then
176
+ // the subscription OAuth path (only works with extra usage credits).
177
+ anthropic: { order: 5, title: 'Anthropic API Key' },
178
+ 'claude-code': { order: 6, title: 'Anthropic OAuth: Required Extra Usage Credits to Use Subscription' }
179
+ }
180
+
181
+ const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
182
+
183
+ const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
184
+ const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
185
+
186
+ export const sortProviders = (providers: OAuthProvider[]) =>
187
+ [...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
188
+
189
+ // Exit choreography, mirroring the gateway "connecting" overlay's timing:
190
+ // text-out (360ms: CONNECTED fades down, rest scrambles+fades) → hold (300ms)
191
+ // → surface-out (520ms, held back by [transition-delay:660ms]). Finalize after.
192
+ const ONBOARDING_EXIT_MS = 1180
193
+
194
+ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
195
+ const { t } = useI18n()
196
+ const onboarding = useStore($desktopOnboarding)
197
+ const boot = useStore($desktopBoot)
198
+ const ctxRef = useRef<OnboardingContext>({ requestGateway, onCompleted })
199
+ ctxRef.current = { requestGateway, onCompleted }
200
+
201
+ const ctx = useMemo<OnboardingContext>(
202
+ () => ({
203
+ requestGateway: (...args) => ctxRef.current.requestGateway(...args),
204
+ onCompleted: () => ctxRef.current.onCompleted?.()
205
+ }),
206
+ []
207
+ )
208
+
209
+ // Cinematic exit on "Begin": dissolve the panel + overlay (revealing the chat
210
+ // behind), THEN finalize so the unmount lands after the fade — mirrors the
211
+ // connecting overlay's exit choreography instead of cutting instantly.
212
+ const [leaving, setLeaving] = useState(false)
213
+
214
+ const finalizeOnboarding = () => {
215
+ if (leaving) {
216
+ return
217
+ }
218
+
219
+ const reduce =
220
+ typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
221
+
222
+ if (reduce) {
223
+ confirmOnboardingModel(ctx)
224
+
225
+ return
226
+ }
227
+
228
+ setLeaving(true)
229
+ window.setTimeout(() => confirmOnboardingModel(ctx), ONBOARDING_EXIT_MS)
230
+ }
231
+
232
+ useEffect(() => {
233
+ if (enabled || onboarding.requested) {
234
+ void refreshOnboarding(ctx)
235
+ }
236
+ }, [ctx, enabled, onboarding.requested])
237
+
238
+ // When the Providers settings page asked to connect a specific provider, the
239
+ // store stashed its id. Once the provider list has loaded and we're back at
240
+ // an idle picker, launch that exact OAuth flow so the user lands directly in
241
+ // sign-in instead of the picker they just came from.
242
+ useEffect(() => {
243
+ if (!onboarding.manual || onboarding.providers === null || onboarding.flow.status !== 'idle') {
244
+ return
245
+ }
246
+
247
+ const pendingId = peekPendingProviderOAuth()
248
+
249
+ if (!pendingId) {
250
+ return
251
+ }
252
+
253
+ const provider = onboarding.providers.find(p => p.id === pendingId)
254
+
255
+ if (provider) {
256
+ // Only clear once we've committed to launching it, so a failed/empty
257
+ // provider fetch doesn't silently drop the hand-off.
258
+ clearPendingProviderOAuth()
259
+ void startProviderOAuth(provider, ctx)
260
+ } else if (onboarding.providers.length > 0) {
261
+ // The list loaded but the id isn't a real provider — drop the stale
262
+ // hand-off. An empty list means the fetch isn't ready yet, so keep it
263
+ // and let a later refresh retry.
264
+ clearPendingProviderOAuth()
265
+ }
266
+ }, [ctx, onboarding.flow.status, onboarding.manual, onboarding.providers])
267
+
268
+ // Mount from frame 1 so we replace the boot overlay seamlessly. The
269
+ // configured field stays null until the runtime check resolves; only then
270
+ // do we know whether to dismiss (true) or surface the picker (false).
271
+ // EXCEPTION: manual mode (user opened the selector from a working app to
272
+ // add/switch a provider) shows the overlay regardless of configured state.
273
+ if (onboarding.configured === true && !onboarding.manual) {
274
+ return null
275
+ }
276
+
277
+ // The user chose "I'll choose a provider later" on first run. Stay out of the
278
+ // way on every subsequent launch — they re-enter via Settings → Providers
279
+ // (manual mode), which sets manual=true and bypasses this gate.
280
+ if (onboarding.firstRunSkipped && !onboarding.manual) {
281
+ return null
282
+ }
283
+
284
+ const { flow } = onboarding
285
+ // Show the launch reason only when it's a meaningful, caller-supplied prompt —
286
+ // suppress the generic defaults (useless noise) and provider-setup errors
287
+ // (those are surfaced by FlowPanel, not as a banner).
288
+ const rawReason = onboarding.reason?.trim() || null
289
+
290
+ const reason =
291
+ rawReason &&
292
+ !isProviderSetupErrorMessage(rawReason) &&
293
+ rawReason !== DEFAULT_ONBOARDING_REASON &&
294
+ rawReason !== DEFAULT_MANUAL_ONBOARDING_REASON
295
+ ? rawReason
296
+ : null
297
+
298
+ // In manual mode the app is already configured, so the flow is "ready"
299
+ // immediately — no runtime gate needed. Otherwise wait for the readiness
300
+ // check (configured === false) before showing the picker.
301
+ const ready = onboarding.manual || (enabled && onboarding.configured === false)
302
+ const showPicker = flow.status === 'idle' || flow.status === 'success'
303
+ // The final "you're in" screen drops the card chrome and floats centered on
304
+ // the surface — same bare, cinematic treatment as the connecting overlay.
305
+ const bare = ready && !showPicker && flow.status === 'confirming_model'
306
+
307
+ return (
308
+ <div
309
+ className={cn(
310
+ 'fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6 transition-opacity duration-[520ms] ease-out',
311
+ // On the bare confirm screen, hold the surface (text-out + hold) so the
312
+ // per-element exit plays before it dissolves.
313
+ bare && leaving ? '[transition-delay:660ms]' : '',
314
+ leaving ? 'pointer-events-none opacity-0' : 'opacity-100'
315
+ )}
316
+ >
317
+ <div
318
+ className={cn(
319
+ 'relative w-full max-w-[45rem] transition-all duration-500 ease-out',
320
+ bare
321
+ ? ''
322
+ : 'overflow-hidden rounded-xl border border-(--stroke-nastech) bg-(--ui-chat-bubble-background) shadow-nastech',
323
+ // Bare confirm screen orchestrates its own per-element exit; the
324
+ // carded states use the simple lift/blur dissolve.
325
+ leaving && !bare
326
+ ? '-translate-y-1 scale-[0.985] opacity-0 blur-[2px]'
327
+ : 'translate-y-0 scale-100 opacity-100 blur-0'
328
+ )}
329
+ >
330
+ {showPicker || !ready ? <Header /> : null}
331
+ {onboarding.manual ? (
332
+ <Button
333
+ aria-label={t.common.close}
334
+ className="absolute right-3 top-3 z-10 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
335
+ onClick={() => closeManualOnboarding()}
336
+ size="icon-sm"
337
+ variant="ghost"
338
+ >
339
+ <Codicon name="close" size="1rem" />
340
+ </Button>
341
+ ) : null}
342
+ <div className="grid gap-3 p-5">
343
+ {reason ? <ReasonNotice reason={reason} /> : null}
344
+ {ready ? (
345
+ showPicker ? (
346
+ <Picker ctx={ctx} />
347
+ ) : (
348
+ <FlowPanel ctx={ctx} flow={flow} leaving={leaving} onBegin={finalizeOnboarding} />
349
+ )
350
+ ) : (
351
+ <Preparing boot={boot} />
352
+ )}
353
+ </div>
354
+ </div>
355
+ </div>
356
+ )
357
+ }
358
+
359
+ // The launch reason is a prompt ("why am I seeing this"), not an error. Only
360
+ // rendered for meaningful caller-supplied reasons (defaults are filtered out
361
+ // upstream), so it never shows the generic "no provider configured" noise.
362
+ function ReasonNotice({ reason }: { reason: string }) {
363
+ return (
364
+ <div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
365
+ {reason}
366
+ </div>
367
+ )
368
+ }
369
+
370
+ function Preparing({ boot }: { boot: DesktopBootState }) {
371
+ const { t } = useI18n()
372
+ const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
373
+ const hasError = Boolean(boot.error)
374
+ const installing = boot.phase.startsWith('runtime.')
375
+
376
+ return (
377
+ <div className="grid gap-3" role="status">
378
+ <p className="text-sm text-muted-foreground">
379
+ {installing ? t.onboarding.preparingInstall : t.onboarding.starting}
380
+ </p>
381
+ <div className="h-2 overflow-hidden rounded-full bg-muted">
382
+ <div
383
+ className={cn(
384
+ 'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
385
+ hasError && 'bg-destructive'
386
+ )}
387
+ style={{ width: `${progress}%` }}
388
+ />
389
+ </div>
390
+ <div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
391
+ <span className="truncate">{boot.message}</span>
392
+ <span>{progress}%</span>
393
+ </div>
394
+ {hasError ? <p className="text-xs text-destructive">{boot.error}</p> : null}
395
+ </div>
396
+ )
397
+ }
398
+
399
+ function Header() {
400
+ const { t } = useI18n()
401
+
402
+ return (
403
+ <div className="bg-(--ui-chat-bubble-background) px-5 pt-5 pb-1">
404
+ <h2 className="text-[0.9375rem] font-semibold tracking-tight">{t.onboarding.headerTitle}</h2>
405
+ <p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">{t.onboarding.headerDesc}</p>
406
+ </div>
407
+ )
408
+ }
409
+
410
+ export const FEATURED_ID = 'nastech'
411
+ const SHOW_ALL_KEY = 'nastech-onboarding-show-all-v1'
412
+
413
+ const readShowAll = () => {
414
+ try {
415
+ return window.localStorage.getItem(SHOW_ALL_KEY) === '1'
416
+ } catch {
417
+ return false
418
+ }
419
+ }
420
+
421
+ const persistShowAll = (value: boolean) => {
422
+ try {
423
+ window.localStorage.setItem(SHOW_ALL_KEY, value ? '1' : '0')
424
+ } catch {
425
+ // localStorage unavailable — degrade silently.
426
+ }
427
+
428
+ return value
429
+ }
430
+
431
+ export function Picker({ ctx }: { ctx: OnboardingContext }) {
432
+ const { t } = useI18n()
433
+ const { manual, mode, providers } = useStore($desktopOnboarding)
434
+ const [showAll, setShowAll] = useState(readShowAll)
435
+ const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
436
+ const hasOauth = ordered.length > 0
437
+ const apiKeyOptions = useApiKeyCatalog()
438
+
439
+ if (mode === 'apikey' || !hasOauth) {
440
+ return (
441
+ <div className="grid gap-3">
442
+ <ApiKeyForm
443
+ canGoBack={hasOauth}
444
+ onBack={() => setOnboardingMode('oauth')}
445
+ onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
446
+ options={apiKeyOptions}
447
+ />
448
+ {manual ? null : (
449
+ <div className="flex justify-center border-t border-(--ui-stroke-tertiary) pt-3">
450
+ <ChooseLaterLink />
451
+ </div>
452
+ )}
453
+ </div>
454
+ )
455
+ }
456
+
457
+ if (providers === null) {
458
+ return <Status>{t.onboarding.lookingUpProviders}</Status>
459
+ }
460
+
461
+ const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx)
462
+ const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
463
+ const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
464
+ // Collapse the secondary providers behind a disclosure only when NasTech Portal
465
+ // Portal is present to anchor the choice — otherwise show the full list.
466
+ const collapsible = Boolean(featured) && rest.length > 0
467
+ const showRest = !collapsible || showAll
468
+
469
+ return (
470
+ <div className="grid gap-2">
471
+ <div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1">
472
+ {featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
473
+ {showRest ? (
474
+ <>
475
+ {rest.map(p => (
476
+ <ProviderRow key={p.id} onSelect={select} provider={p} />
477
+ ))}
478
+ <KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
479
+ </>
480
+ ) : null}
481
+ </div>
482
+ {collapsible ? (
483
+ <Button
484
+ className="mt-1 self-center font-medium"
485
+ onClick={() => setShowAll(persistShowAll(!showAll))}
486
+ size="xs"
487
+ type="button"
488
+ variant="text"
489
+ >
490
+ {showAll ? t.onboarding.collapse : t.onboarding.otherProviders}
491
+ <ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
492
+ </Button>
493
+ ) : null}
494
+ <div className="flex items-center justify-between gap-3 pt-1">
495
+ {/* First run only: let the user defer the choice and land in the app.
496
+ In manual mode the overlay already has a close affordance, so the
497
+ "choose later" escape would be redundant — hide it. */}
498
+ {manual ? <span /> : <ChooseLaterLink />}
499
+ <Button
500
+ className="-mr-2 font-medium"
501
+ onClick={() => setOnboardingMode('apikey')}
502
+ size="xs"
503
+ type="button"
504
+ variant="text"
505
+ >
506
+ {t.onboarding.haveApiKey}
507
+ </Button>
508
+ </div>
509
+ </div>
510
+ )
511
+ }
512
+
513
+ // "I'll choose a provider later" — dismisses the first-run picker and persists
514
+ // the skip so it never re-nags. The user connects a provider any time from
515
+ // Settings → Providers. Rendered only on the unconfigured first-run flow.
516
+ function ChooseLaterLink() {
517
+ const { t } = useI18n()
518
+
519
+ return (
520
+ <Button
521
+ className="font-medium"
522
+ onClick={() => dismissFirstRunOnboarding()}
523
+ size="xs"
524
+ type="button"
525
+ variant="text"
526
+ >
527
+ {t.onboarding.chooseLater}
528
+ </Button>
529
+ )
530
+ }
531
+
532
+ export function FeaturedProviderRow({
533
+ onSelect,
534
+ provider
535
+ }: {
536
+ onSelect: (provider: OAuthProvider) => void
537
+ provider: OAuthProvider
538
+ }) {
539
+ const { t } = useI18n()
540
+ const loggedIn = provider.status?.logged_in
541
+
542
+ return (
543
+ <button
544
+ className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
545
+ onClick={() => onSelect(provider)}
546
+ type="button"
547
+ >
548
+ <span aria-hidden className="arc-border arc-reverse arc-nastech" />
549
+ <div className="min-w-0">
550
+ <div className="flex items-center gap-2">
551
+ <img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
552
+ <span className="text-[length:var(--conversation-text-font-size)] font-semibold">
553
+ {providerTitle(provider)}
554
+ </span>
555
+ {loggedIn ? (
556
+ <ConnectedTag />
557
+ ) : (
558
+ <span className="inline-flex items-center gap-1.5 bg-primary px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-primary-foreground">
559
+ <span aria-hidden="true" className="dither inline-block size-2 shrink-0" />
560
+ {t.onboarding.recommended}
561
+ </span>
562
+ )}
563
+ </div>
564
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.featuredPitch}</p>
565
+ </div>
566
+ <ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
567
+ </button>
568
+ )
569
+ }
570
+
571
+ function ConnectedTag() {
572
+ const { t } = useI18n()
573
+
574
+ return (
575
+ <span className="inline-flex items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
576
+ <Check className="size-3" />
577
+ {t.onboarding.connected}
578
+ </span>
579
+ )
580
+ }
581
+
582
+ const PROVIDER_ROW_CLASS =
583
+ 'group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)'
584
+
585
+ export function KeyProviderRow({ onClick }: { onClick: () => void }) {
586
+ const { t } = useI18n()
587
+
588
+ return (
589
+ <button className={PROVIDER_ROW_CLASS} onClick={onClick} type="button">
590
+ <div className="min-w-0">
591
+ <span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
592
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
593
+ </div>
594
+ <ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
595
+ </button>
596
+ )
597
+ }
598
+
599
+ export function ProviderRow({
600
+ onSelect,
601
+ provider
602
+ }: {
603
+ onSelect: (provider: OAuthProvider) => void
604
+ provider: OAuthProvider
605
+ }) {
606
+ const { t } = useI18n()
607
+ const loggedIn = provider.status?.logged_in
608
+ const Trail = provider.flow === 'external' ? Terminal : ChevronRight
609
+
610
+ return (
611
+ <button className={PROVIDER_ROW_CLASS} onClick={() => onSelect(provider)} type="button">
612
+ <div className="min-w-0">
613
+ <div className="flex items-center gap-2">
614
+ <span className="text-[length:var(--conversation-text-font-size)] font-semibold">
615
+ {providerTitle(provider)}
616
+ </span>
617
+ {loggedIn ? <ConnectedTag /> : null}
618
+ </div>
619
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
620
+ </div>
621
+ <Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
622
+ </button>
623
+ )
624
+ }
625
+
626
+ // Presentational two-column key picker. Onboarding feeds it its curated
627
+ // options + a ctx-bound save; the Providers settings page feeds it the full
628
+ // provider catalog + a setEnvVar-backed save (plus `isSet`/`onClear` so it can
629
+ // double as a manage surface). Keep it free of store/ctx coupling so both
630
+ // surfaces render the identical form.
631
+ export function ApiKeyForm({
632
+ canGoBack,
633
+ isSet,
634
+ onBack,
635
+ onClear,
636
+ onSave,
637
+ options = API_KEY_OPTIONS,
638
+ redactedValue
639
+ }: {
640
+ canGoBack: boolean
641
+ isSet?: (envKey: string) => boolean
642
+ onBack: () => void
643
+ onClear?: (envKey: string) => void
644
+ onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
645
+ options?: ApiKeyOption[]
646
+ redactedValue?: (envKey: string) => null | string | undefined
647
+ }) {
648
+ const { t } = useI18n()
649
+ const [option, setOption] = useState<ApiKeyOption>(options[0])
650
+ const [value, setValue] = useState('')
651
+ const [saving, setSaving] = useState(false)
652
+ const [error, setError] = useState<null | string>(null)
653
+ // `options` can change at runtime when callers filter the catalog (e.g. the
654
+ // Providers page wiring its search into this grid). Keep the selection valid
655
+ // by snapping back to the first remaining option when the current one drops.
656
+ useEffect(() => {
657
+ if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
658
+ setOption(options[0])
659
+ setValue('')
660
+ setError(null)
661
+ }
662
+ }, [option.envKey, options])
663
+ // The catalog grid can be tall, leaving the entry field far below the fold.
664
+ // On selection we scroll the field into view and focus it so it's always
665
+ // obvious where to paste next.
666
+ const entryRef = useRef<HTMLDivElement>(null)
667
+
668
+ const pick = (o: ApiKeyOption) => {
669
+ setOption(o)
670
+ setValue('')
671
+ setError(null)
672
+ requestAnimationFrame(() => {
673
+ entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
674
+ entryRef.current?.querySelector('input')?.focus()
675
+ })
676
+ }
677
+
678
+ const isLocal = option.envKey === 'OPENAI_BASE_URL'
679
+ const alreadySet = isSet?.(option.envKey) ?? false
680
+ // When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
681
+ // placeholder so users can eyeball that the right key is in place.
682
+ const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
683
+ // Only require a non-empty value — no length/format validation, so a short
684
+ // or unusual key can't block the user from continuing.
685
+ const canSave = value.trim().length >= 1
686
+ const optionCopy = t.onboarding.apiKeyOptions[option.id]
687
+ const optionDescription = optionCopy?.description ?? option.description
688
+
689
+ const submit = async () => {
690
+ if (!canSave || saving) {
691
+ return
692
+ }
693
+
694
+ setSaving(true)
695
+ setError(null)
696
+ const result = await onSave(option.envKey, value, option.name)
697
+
698
+ if (result.ok) {
699
+ setValue('')
700
+ } else {
701
+ setError(result.message ?? t.onboarding.couldNotSave)
702
+ }
703
+
704
+ setSaving(false)
705
+ }
706
+
707
+ return (
708
+ <div className="grid gap-4">
709
+ {canGoBack ? (
710
+ <Button
711
+ className="-mt-1 self-start font-medium"
712
+ onClick={onBack}
713
+ size="xs"
714
+ type="button"
715
+ variant="text"
716
+ >
717
+ <ChevronLeft className="size-3" />
718
+ {t.onboarding.backToSignIn}
719
+ </Button>
720
+ ) : null}
721
+
722
+ <div className="grid max-h-[42dvh] gap-2 overflow-y-auto p-1 sm:grid-cols-2">
723
+ {options.map(o => (
724
+ <button
725
+ className={cn(
726
+ 'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
727
+ option.envKey === o.envKey ? 'border-primary ring-2 ring-primary/20' : 'border-transparent'
728
+ )}
729
+ key={o.envKey}
730
+ onClick={() => pick(o)}
731
+ type="button"
732
+ >
733
+ <div className="flex items-center justify-between gap-2">
734
+ <span className="text-sm font-medium">{o.name}</span>
735
+ {isSet?.(o.envKey) ? <Check className="size-3.5 text-muted-foreground" /> : null}
736
+ </div>
737
+ {(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? (
738
+ <p className="mt-1 text-xs text-muted-foreground">{t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}</p>
739
+ ) : null}
740
+ </button>
741
+ ))}
742
+ </div>
743
+
744
+ <div className="grid scroll-mt-4 gap-2" ref={entryRef}>
745
+ <div className="flex items-center justify-between gap-3">
746
+ <p className="text-sm leading-6 text-muted-foreground">{optionDescription}</p>
747
+ {option.docsUrl ? <DocsLink href={option.docsUrl}>{t.onboarding.getKey}</DocsLink> : null}
748
+ </div>
749
+ <Input
750
+ autoComplete="off"
751
+ autoFocus
752
+ className="font-mono"
753
+ onChange={e => setValue(e.target.value)}
754
+ onKeyDown={e => e.key === 'Enter' && void submit()}
755
+ placeholder={
756
+ currentRedacted ??
757
+ (alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey)
758
+ }
759
+ type={isLocal ? 'text' : 'password'}
760
+ value={value}
761
+ />
762
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
763
+ </div>
764
+
765
+ <div className="flex items-center justify-between gap-3">
766
+ <div>
767
+ {alreadySet && onClear ? (
768
+ <Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
769
+ {t.common.remove}
770
+ </Button>
771
+ ) : null}
772
+ </div>
773
+ <Button disabled={!canSave || saving} onClick={() => void submit()}>
774
+ {saving ? <Loader2 className="animate-spin" /> : <KeyRound />}
775
+ {saving ? t.onboarding.connecting : alreadySet ? t.onboarding.update : t.common.connect}
776
+ </Button>
777
+ </div>
778
+ </div>
779
+ )
780
+ }
781
+
782
+ function FlowPanel({
783
+ ctx,
784
+ flow,
785
+ leaving,
786
+ onBegin
787
+ }: {
788
+ ctx: OnboardingContext
789
+ flow: OnboardingFlow
790
+ leaving: boolean
791
+ onBegin: () => void
792
+ }) {
793
+ const { t } = useI18n()
794
+ const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
795
+
796
+ if (flow.status === 'starting') {
797
+ return <Status>{t.onboarding.startingSignIn(title)}</Status>
798
+ }
799
+
800
+ if (flow.status === 'submitting') {
801
+ return <Status>{t.onboarding.verifyingCode(title)}</Status>
802
+ }
803
+
804
+ if (flow.status === 'success') {
805
+ return (
806
+ <DecodedLabel text={t.onboarding.connectedPicking(title)} />
807
+ )
808
+ }
809
+
810
+ if (flow.status === 'confirming_model') {
811
+ return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} />
812
+ }
813
+
814
+ if (flow.status === 'error') {
815
+ return (
816
+ <div className="grid gap-3">
817
+ <div className="flex items-center gap-1.5 text-sm text-destructive">
818
+ <ErrorIcon className="shrink-0" size="0.875rem" />
819
+ <span>{flow.message || t.onboarding.signInFailed}</span>
820
+ </div>
821
+ <div className="flex justify-end">
822
+ <Button onClick={cancelOnboardingFlow} variant="outline">
823
+ {t.onboarding.pickDifferentProvider}
824
+ </Button>
825
+ </div>
826
+ </div>
827
+ )
828
+ }
829
+
830
+ if (flow.status === 'awaiting_user') {
831
+ return (
832
+ <Step title={t.onboarding.signInWith(title)}>
833
+ <ol className="list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
834
+ <li>{t.onboarding.openedBrowser(title)}</li>
835
+ <li>{t.onboarding.authorizeThere}</li>
836
+ <li>{t.onboarding.copyAuthCode}</li>
837
+ </ol>
838
+ <Input
839
+ autoFocus
840
+ onChange={e => setOnboardingCode(e.target.value)}
841
+ onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)}
842
+ placeholder={t.onboarding.pasteAuthCode}
843
+ value={flow.code}
844
+ />
845
+ <FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenAuthPage}</DocsLink>}>
846
+ <CancelBtn />
847
+ <Button disabled={!flow.code.trim()} onClick={() => void submitOnboardingCode(ctx)}>
848
+ {t.common.continue}
849
+ </Button>
850
+ </FlowFooter>
851
+ </Step>
852
+ )
853
+ }
854
+
855
+ if (flow.status === 'awaiting_browser') {
856
+ return (
857
+ <Step title={t.onboarding.signInWith(title)}>
858
+ <p className="text-sm text-muted-foreground">{t.onboarding.autoBrowser(title)}</p>
859
+ <FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenSignInPage}</DocsLink>}>
860
+ <span className="flex items-center gap-2 text-xs text-muted-foreground">
861
+ <Loader2 className="size-3 animate-spin" />
862
+ {t.onboarding.waitingAuthorize}
863
+ </span>
864
+ <CancelBtn size="sm" />
865
+ </FlowFooter>
866
+ </Step>
867
+ )
868
+ }
869
+
870
+ if (flow.status === 'external_pending') {
871
+ return (
872
+ <Step title={t.onboarding.signInWith(title)}>
873
+ <p className="text-sm text-muted-foreground">{t.onboarding.externalPending(title)}</p>
874
+ <CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
875
+ <FlowFooter
876
+ left={
877
+ flow.provider.docs_url ? (
878
+ <DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink>
879
+ ) : null
880
+ }
881
+ >
882
+ <CancelBtn />
883
+ <Button onClick={() => void recheckExternalSignin(ctx)}>{t.onboarding.signedIn}</Button>
884
+ </FlowFooter>
885
+ </Step>
886
+ )
887
+ }
888
+
889
+ if (flow.status !== 'polling') {
890
+ return null
891
+ }
892
+
893
+ return (
894
+ <Step title={t.onboarding.signInWith(title)}>
895
+ <p className="text-sm text-muted-foreground">{t.onboarding.deviceCodeOpened(title)}</p>
896
+ <DeviceCode code={flow.start.user_code} copied={flow.copied} onCopy={() => void copyDeviceCode()} />
897
+ <FlowFooter left={<DocsLink href={flow.start.verification_url}>{t.onboarding.reopenVerification}</DocsLink>}>
898
+ <span className="flex items-center gap-2 text-xs text-muted-foreground">
899
+ <Loader2 className="size-3 animate-spin" />
900
+ {t.onboarding.waitingAuthorize}
901
+ </span>
902
+ <CancelBtn size="sm" />
903
+ </FlowFooter>
904
+ </Step>
905
+ )
906
+ }
907
+
908
+ function Step({ children, title }: { children: React.ReactNode; title: string }) {
909
+ return (
910
+ <div className="grid gap-4">
911
+ <h3 className="text-sm font-semibold">{title}</h3>
912
+ {children}
913
+ </div>
914
+ )
915
+ }
916
+
917
+ // Device-code display: OTP-style — each character in its own readonly cell.
918
+ // The whole row is the copy button (no side button, no checkmark); on copy the
919
+ // cells flash emerald for feedback. Dashes render as quiet separators.
920
+ function DeviceCode({ code, copied, onCopy }: { code: string; copied: boolean; onCopy: () => void }) {
921
+ const { t } = useI18n()
922
+
923
+ return (
924
+ <button
925
+ aria-label={t.onboarding.copy}
926
+ className="group flex w-full items-center justify-center gap-1.5"
927
+ onClick={onCopy}
928
+ type="button"
929
+ >
930
+ {[...code].map((ch, i) =>
931
+ ch === '-' || ch === ' ' ? (
932
+ <span className="w-1.5 text-center text-lg text-muted-foreground" key={i}>
933
+
934
+ </span>
935
+ ) : (
936
+ <span
937
+ className={cn(
938
+ 'flex size-10 items-center justify-center rounded-md border font-mono text-xl font-semibold uppercase transition-colors',
939
+ copied
940
+ ? 'border-primary/50 text-primary'
941
+ : 'border-(--stroke-nastech) text-foreground group-hover:border-(--ui-stroke-secondary)'
942
+ )}
943
+ key={i}
944
+ >
945
+ {ch}
946
+ </span>
947
+ )
948
+ )}
949
+ </button>
950
+ )
951
+ }
952
+
953
+ function CodeBlock({ copied, onCopy, text }: { copied: boolean; onCopy: () => void; text: string }) {
954
+ const { t } = useI18n()
955
+
956
+ return (
957
+ <div className="flex items-center justify-between gap-3 rounded-md border border-(--stroke-nastech) px-3 py-2">
958
+ <code className="min-w-0 flex-1 truncate font-mono text-sm">
959
+ <span className="mr-2 select-none text-muted-foreground">$</span>
960
+ {text}
961
+ </code>
962
+ <Button onClick={onCopy} size="sm" variant="outline">
963
+ {copied ? t.common.copied : t.onboarding.copy}
964
+ </Button>
965
+ </div>
966
+ )
967
+ }
968
+
969
+ function FlowFooter({ children, left }: { children: React.ReactNode; left?: React.ReactNode }) {
970
+ return (
971
+ <div className="flex items-center justify-between gap-3">
972
+ <div className="min-w-0">{left}</div>
973
+ <div className="flex items-center gap-3">{children}</div>
974
+ </div>
975
+ )
976
+ }
977
+
978
+ function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
979
+ const { t } = useI18n()
980
+
981
+ return (
982
+ <Button onClick={cancelOnboardingFlow} size={size} variant="ghost">
983
+ {t.common.cancel}
984
+ </Button>
985
+ )
986
+ }
987
+
988
+ // Borrowed from the gateway "connecting" overlay: a mono, letter-spaced label
989
+ // that decodes left-to-right from scrambled glyphs into the real text, with a
990
+ // blinking block cursor. Ties onboarding's success moment to that same motif.
991
+ // Cuneiform glyphs (array, since each is a surrogate pair) for the scramble.
992
+ // Hero "X CONNECTED" decode uses the SAME ascii map as the connecting overlay.
993
+ const ASCII_GLYPHS = [...'/\\|-_=+<>~:*']
994
+ const pickAscii = () => ASCII_GLYPHS[(Math.random() * ASCII_GLYPHS.length) | 0]
995
+ // Cuneiform is reserved for the subtle "other text" (model name + BEGIN) easter egg.
996
+ const SCRAMBLE_GLYPHS = [...'𒀀𒀁𒀂𒀅𒀊𒀖𒀜𒀭𒀲𒀸𒁀𒁉𒁒𒁕𒁹𒂊𒃻𒄆𒄴𒅀𒆍𒇽𒈨𒉡']
997
+ const GLYPH_SET = new Set(SCRAMBLE_GLYPHS)
998
+ const pickGlyph = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0]
999
+ // How many trailing characters of each word scramble during decode-in.
1000
+ const DECODE_TAIL = 4
1001
+
1002
+ // Renders text where cuneiform scramble-glyphs are dropped to a smaller em-size
1003
+ // (resolved Latin chars stay full size) — keeps the easter-egg glyphs subtle.
1004
+ function GlyphText({ text }: { text: string }) {
1005
+ return (
1006
+ <>
1007
+ {Array.from(text, (ch, i) =>
1008
+ GLYPH_SET.has(ch) ? (
1009
+ <span className="text-[0.62em]" key={i}>
1010
+ {ch}
1011
+ </span>
1012
+ ) : (
1013
+ ch
1014
+ )
1015
+ )}
1016
+ </>
1017
+ )
1018
+ }
1019
+
1020
+ function useDecoded(text: string): string {
1021
+ const [out, setOut] = useState(text)
1022
+
1023
+ useEffect(() => {
1024
+ if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
1025
+ setOut(text)
1026
+
1027
+ return
1028
+ }
1029
+
1030
+ // Each WORD keeps its head static and only churns its tail (last few chars),
1031
+ // resolving left-to-right across all tails — same anchor-the-prefix trick the
1032
+ // connecting overlay uses ("CONN" static, "ECTING" churns), applied per word
1033
+ // so both the provider and "CONNECTED" decode and time stays constant.
1034
+ const chars = [...text]
1035
+ const scrambleable = chars.map(() => false)
1036
+
1037
+ for (let i = 0; i < chars.length; ) {
1038
+ if (!/[a-z0-9]/i.test(chars[i])) {
1039
+ i += 1
1040
+
1041
+ continue
1042
+ }
1043
+
1044
+ let j = i
1045
+
1046
+ while (j < chars.length && /[a-z0-9]/i.test(chars[j])) {
1047
+ j += 1
1048
+ }
1049
+
1050
+ for (let k = Math.max(i, j - DECODE_TAIL); k < j; k += 1) {
1051
+ scrambleable[k] = true
1052
+ }
1053
+
1054
+ i = j
1055
+ }
1056
+
1057
+ const tailIndices = chars.map((_, idx) => idx).filter(idx => scrambleable[idx])
1058
+ let resolved = 0
1059
+
1060
+ const id = window.setInterval(() => {
1061
+ resolved += 0.5
1062
+ const settled = new Set(tailIndices.slice(0, Math.floor(resolved)))
1063
+
1064
+ setOut(chars.map((ch, idx) => (scrambleable[idx] && !settled.has(idx) ? pickAscii() : ch)).join(''))
1065
+
1066
+ if (Math.floor(resolved) >= tailIndices.length) {
1067
+ window.clearInterval(id)
1068
+ }
1069
+ }, 45)
1070
+
1071
+ return () => window.clearInterval(id)
1072
+ }, [text])
1073
+
1074
+ return out
1075
+ }
1076
+
1077
+ // Continuously scrambles alphanumeric chars while `active` (used on exit so the
1078
+ // model name / button decay into ascii noise as they fade).
1079
+ function useScramble(text: string, active: boolean): string {
1080
+ const [out, setOut] = useState(text)
1081
+
1082
+ useEffect(() => {
1083
+ if (!active) {
1084
+ setOut(text)
1085
+
1086
+ return
1087
+ }
1088
+
1089
+ const id = window.setInterval(() => {
1090
+ setOut(Array.from(text, ch => (/[a-z0-9]/i.test(ch) ? pickGlyph() : ch)).join(''))
1091
+ }, 45)
1092
+
1093
+ return () => window.clearInterval(id)
1094
+ }, [text, active])
1095
+
1096
+ return out
1097
+ }
1098
+
1099
+ function DecodedLabel({ leaving, text }: { leaving?: boolean; text: string }) {
1100
+ const decoded = useDecoded(text.toUpperCase())
1101
+
1102
+ return (
1103
+ <span
1104
+ className={cn(
1105
+ 'inline-flex items-center font-mono text-xs font-semibold uppercase tracking-[0.28em] tabular-nums text-primary transition duration-[360ms] ease-out',
1106
+ leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
1107
+ )}
1108
+ >
1109
+ <GlyphText text={decoded} />
1110
+ <span
1111
+ aria-hidden="true"
1112
+ className="dither ml-1.5 -mr-[0.875rem] inline-block size-2 shrink-0 -translate-y-px rounded-[1px] text-primary"
1113
+ style={{ animation: 'ob-decode-cursor 1s step-end infinite' }}
1114
+ />
1115
+ <style>{'@keyframes ob-decode-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
1116
+ </span>
1117
+ )
1118
+ }
1119
+
1120
+ // Terminal-flavored CTA to match the connecting overlay's hacker aesthetic:
1121
+ // mono, uppercase, letter-spaced, wrapped in primary brackets that light up on
1122
+ // hover. The whole onboarding "you're in" moment leans into this motif.
1123
+ function HackeryButton({
1124
+ disabled,
1125
+ label,
1126
+ loading,
1127
+ onClick
1128
+ }: {
1129
+ disabled?: boolean
1130
+ label: React.ReactNode
1131
+ loading?: boolean
1132
+ onClick: () => void
1133
+ }) {
1134
+ return (
1135
+ <button
1136
+ className={cn(
1137
+ 'group inline-flex items-center gap-2 rounded-md border border-(--stroke-nastech) px-6 py-2.5',
1138
+ 'font-mono text-xs font-semibold uppercase text-primary',
1139
+ 'transition-all duration-150 hover:border-primary/60 hover:bg-primary/[0.06]',
1140
+ 'disabled:pointer-events-none disabled:opacity-50'
1141
+ )}
1142
+ disabled={disabled}
1143
+ onClick={onClick}
1144
+ type="button"
1145
+ >
1146
+ <span className="text-primary/40 transition-colors group-hover:text-primary">[</span>
1147
+ {loading ? <Loader2 className="size-3 animate-spin" /> : null}
1148
+ <span className="-mr-[0.25em] pl-[0.25em] tracking-[0.25em]">{label}</span>
1149
+ <span className="text-primary/40 transition-colors group-hover:text-primary">]</span>
1150
+ </button>
1151
+ )
1152
+ }
1153
+
1154
+ function ConfirmingModelPanel({
1155
+ flow,
1156
+ leaving,
1157
+ onBegin
1158
+ }: {
1159
+ flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
1160
+ leaving: boolean
1161
+ onBegin: () => void
1162
+ }) {
1163
+ const { t } = useI18n()
1164
+ const scrambledModel = useScramble(flow.currentModel, leaving)
1165
+ const scrambledBegin = useScramble(t.onboarding.startChatting, leaving)
1166
+ // Local state controls whether the model picker dialog is open.
1167
+ // We reuse the existing ModelPickerDialog component (the same picker
1168
+ // available from the chat shell) rather than building an inline
1169
+ // dropdown — gives us search, multi-provider listing if relevant, and
1170
+ // a familiar UI for users who'll see this picker again later.
1171
+ const [pickerOpen, setPickerOpen] = useState(false)
1172
+
1173
+ // Pull pricing + tier for the just-picked default so the confirm card
1174
+ // shows the same $/Mtok + Free/Pro info the picker and CLI do.
1175
+ const options = useQuery({
1176
+ queryKey: ['onboarding-model-options', flow.providerSlug],
1177
+ queryFn: () => getGlobalModelOptions()
1178
+ })
1179
+
1180
+ const providerRow = options.data?.providers?.find(
1181
+ p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase()
1182
+ )
1183
+
1184
+ const price = providerRow?.pricing?.[flow.currentModel]
1185
+ const freeTier = providerRow?.free_tier
1186
+
1187
+ return (
1188
+ <div className="grid place-items-center gap-7 py-6 text-center">
1189
+ <DecodedLabel leaving={leaving} text={t.onboarding.connectedProvider(flow.label)} />
1190
+
1191
+ <div
1192
+ className={cn(
1193
+ 'grid justify-items-center gap-1.5 transition duration-[360ms] ease-out',
1194
+ leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
1195
+ )}
1196
+ >
1197
+ <div className="flex items-center gap-2">
1198
+ <span className="font-mono text-[0.625rem] uppercase tracking-[0.2em] text-muted-foreground">
1199
+ {t.onboarding.defaultModel}
1200
+ </span>
1201
+ {freeTier === true && (
1202
+ <span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
1203
+ {t.onboarding.freeTier}
1204
+ </span>
1205
+ )}
1206
+ {freeTier === false && (
1207
+ <span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
1208
+ {t.onboarding.pro}
1209
+ </span>
1210
+ )}
1211
+ </div>
1212
+ <p className="font-mono text-base">
1213
+ <GlyphText text={scrambledModel} />
1214
+ </p>
1215
+ {price && (price.input || price.output) && (
1216
+ <p className="font-mono text-xs text-muted-foreground">
1217
+ {price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
1218
+ </p>
1219
+ )}
1220
+ <Button
1221
+ className="mt-0.5 text-xs"
1222
+ disabled={flow.saving}
1223
+ onClick={() => setPickerOpen(true)}
1224
+ size="inline"
1225
+ variant="text"
1226
+ >
1227
+ {t.onboarding.change}
1228
+ </Button>
1229
+ </div>
1230
+
1231
+ <div
1232
+ className={cn(
1233
+ 'transition duration-[360ms] ease-out',
1234
+ leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
1235
+ )}
1236
+ >
1237
+ <HackeryButton
1238
+ disabled={flow.saving}
1239
+ label={<GlyphText text={scrambledBegin} />}
1240
+ loading={flow.saving}
1241
+ onClick={onBegin}
1242
+ />
1243
+ </div>
1244
+
1245
+ {/*
1246
+ ModelPickerDialog defaults to z-130 on its content, which renders
1247
+ UNDER the onboarding overlay (z-1300) and breaks pointer events.
1248
+ Bump it above with z-[1310] so the picker sits on top of the
1249
+ onboarding panel. The dialog's own dim-backdrop layer stays at
1250
+ its default z-120 — the onboarding overlay is already dimming
1251
+ the rest of the screen, so we don't want a second backdrop.
1252
+ */}
1253
+ <ModelPickerDialog
1254
+ contentClassName="z-[1310]"
1255
+ currentModel={flow.currentModel}
1256
+ currentProvider={flow.providerSlug}
1257
+ onOpenChange={setPickerOpen}
1258
+ onSelect={({ model }) => {
1259
+ void setOnboardingModel(model)
1260
+ setPickerOpen(false)
1261
+ }}
1262
+ open={pickerOpen}
1263
+ />
1264
+ </div>
1265
+ )
1266
+ }
1267
+
1268
+ function DocsLink({ children, href }: { children: React.ReactNode; href: string }) {
1269
+ return (
1270
+ <Button asChild size="xs" variant="text">
1271
+ <a href={href} rel="noreferrer" target="_blank">
1272
+ <ExternalLink className="size-3" />
1273
+ {children}
1274
+ </a>
1275
+ </Button>
1276
+ )
1277
+ }
1278
+
1279
+ function Status({ children }: { children: React.ReactNode }) {
1280
+ return (
1281
+ <div className="flex items-center gap-2.5 py-1 text-sm text-muted-foreground" role="status">
1282
+ <Loader className="size-7" type="lemniscate-bloom" />
1283
+ {children}
1284
+ </div>
1285
+ )
1286
+ }