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,115 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import type { SessionInfo } from '@/types/nastech'
4
+
5
+ import { $selectedStoredSessionId, $sessions } from './session'
6
+ import {
7
+ $switcherIndex,
8
+ $switcherOpen,
9
+ $switcherSessions,
10
+ closeSwitcher,
11
+ commitOnCtrlUp,
12
+ onSwitcherTabDown,
13
+ onSwitcherTabUp,
14
+ openOrAdvanceSwitcher,
15
+ slotSessionId,
16
+ SWITCHER_REVEAL_MS
17
+ } from './session-switcher'
18
+
19
+ const session = (id: string): SessionInfo => ({ id }) as SessionInfo
20
+
21
+ const seed = (ids: string[], selected: null | string) => {
22
+ $sessions.set(ids.map(session))
23
+ $selectedStoredSessionId.set(selected)
24
+ }
25
+
26
+ const tabTap = (direction: 1 | -1 = 1) => {
27
+ onSwitcherTabDown()
28
+ const target = openOrAdvanceSwitcher(direction)
29
+ onSwitcherTabUp()
30
+
31
+ return target
32
+ }
33
+
34
+ beforeEach(() => {
35
+ vi.useRealTimers()
36
+ closeSwitcher()
37
+ $switcherSessions.set([])
38
+ $switcherIndex.set(0)
39
+ })
40
+
41
+ afterEach(() => {
42
+ seed([], null)
43
+ })
44
+
45
+ describe('openOrAdvanceSwitcher', () => {
46
+ it('does nothing with fewer than two sessions', () => {
47
+ seed(['a'], 'a')
48
+ onSwitcherTabDown()
49
+
50
+ expect(openOrAdvanceSwitcher(1)).toBeNull()
51
+ })
52
+
53
+ it('jumps immediately on a quick Tab tap without opening the HUD', () => {
54
+ seed(['a', 'b', 'c'], 'a')
55
+
56
+ expect(tabTap()).toBe('b')
57
+ expect($switcherOpen.get()).toBe(false)
58
+ expect(commitOnCtrlUp()).toBeNull()
59
+ })
60
+
61
+ it('does not open the HUD when Ctrl stays down but Tab was released quickly', () => {
62
+ vi.useFakeTimers()
63
+ seed(['a', 'b', 'c'], 'a')
64
+
65
+ tabTap()
66
+ vi.advanceTimersByTime(SWITCHER_REVEAL_MS)
67
+
68
+ expect($switcherOpen.get()).toBe(false)
69
+ })
70
+
71
+ it('opens the HUD when Tab stays held past the reveal delay', () => {
72
+ vi.useFakeTimers()
73
+ seed(['a', 'b', 'c'], 'a')
74
+
75
+ onSwitcherTabDown()
76
+ openOrAdvanceSwitcher(1)
77
+ vi.advanceTimersByTime(SWITCHER_REVEAL_MS)
78
+
79
+ expect($switcherOpen.get()).toBe(true)
80
+ onSwitcherTabUp()
81
+ })
82
+
83
+ it('opens on a second Tab while Ctrl is still down', () => {
84
+ seed(['a', 'b', 'c'], 'a')
85
+
86
+ expect(tabTap()).toBe('b')
87
+ onSwitcherTabDown()
88
+ openOrAdvanceSwitcher(1)
89
+ onSwitcherTabUp()
90
+
91
+ expect($switcherOpen.get()).toBe(true)
92
+ expect($switcherIndex.get()).toBe(2)
93
+ })
94
+
95
+ it('commits the HUD highlight on Ctrl up', () => {
96
+ seed(['a', 'b', 'c'], 'a')
97
+
98
+ expect(tabTap()).toBe('b')
99
+ onSwitcherTabDown()
100
+ openOrAdvanceSwitcher(1)
101
+ onSwitcherTabUp()
102
+
103
+ expect(commitOnCtrlUp()).toBe('c')
104
+ })
105
+ })
106
+
107
+ describe('slotSessionId', () => {
108
+ it('reads the armed snapshot while browsing is pending', () => {
109
+ seed(['a', 'b', 'c'], 'a')
110
+ tabTap()
111
+ $sessions.set([session('x')])
112
+
113
+ expect(slotSessionId(2)).toBe('b')
114
+ })
115
+ })
@@ -0,0 +1,128 @@
1
+ import { atom } from 'nanostores'
2
+
3
+ import type { SessionInfo } from '@/types/nastech'
4
+
5
+ import { $selectedStoredSessionId, $sessions } from './session'
6
+
7
+ // Mac-style session switcher (^Tab). Quick tap jumps on keydown; the HUD opens
8
+ // only when Tab is held past REVEAL_MS or tapped again while Ctrl is down.
9
+
10
+ export const SWITCHER_REVEAL_MS = 220
11
+
12
+ export const $switcherOpen = atom(false)
13
+ export const $switcherSessions = atom<SessionInfo[]>([])
14
+ export const $switcherIndex = atom(0)
15
+
16
+ const wrap = (index: number, length: number): number => ((index % length) + length) % length
17
+
18
+ let pendingBrowse = false
19
+ let revealTimer: ReturnType<typeof setTimeout> | null = null
20
+ let tabHeld = false
21
+ let closedAt = 0
22
+
23
+ function clearRevealTimer(): void {
24
+ if (revealTimer) {
25
+ clearTimeout(revealTimer)
26
+ revealTimer = null
27
+ }
28
+ }
29
+
30
+ function revealOverlay(): void {
31
+ pendingBrowse = false
32
+ $switcherOpen.set(true)
33
+ }
34
+
35
+ function scheduleReveal(): void {
36
+ clearRevealTimer()
37
+ revealTimer = setTimeout(() => {
38
+ revealTimer = null
39
+
40
+ if (pendingBrowse && tabHeld) {
41
+ revealOverlay()
42
+ }
43
+ }, SWITCHER_REVEAL_MS)
44
+ }
45
+
46
+ export function onSwitcherTabDown(): void {
47
+ tabHeld = true
48
+ }
49
+
50
+ export function onSwitcherTabUp(): void {
51
+ tabHeld = false
52
+
53
+ if (!$switcherOpen.get()) {
54
+ clearRevealTimer()
55
+ }
56
+ }
57
+
58
+ // First Tab returns a session id to jump to immediately; later Tabs move the
59
+ // highlight (Ctrl↑ commits when the HUD is open).
60
+ export function openOrAdvanceSwitcher(direction: 1 | -1): string | null {
61
+ const sessions = $sessions.get()
62
+
63
+ if (sessions.length < 2) {
64
+ return null
65
+ }
66
+
67
+ if ($switcherOpen.get()) {
68
+ const { length } = $switcherSessions.get()
69
+
70
+ if (length) {
71
+ $switcherIndex.set(wrap($switcherIndex.get() + direction, length))
72
+ }
73
+
74
+ return null
75
+ }
76
+
77
+ const current = sessions.findIndex(session => session.id === $selectedStoredSessionId.get())
78
+ const start = current === -1 ? (direction === 1 ? -1 : 0) : current
79
+ const nextIndex = wrap(start + direction, sessions.length)
80
+
81
+ $switcherSessions.set(sessions)
82
+ $switcherIndex.set(nextIndex)
83
+
84
+ if (pendingBrowse) {
85
+ clearRevealTimer()
86
+ $switcherIndex.set(wrap($switcherIndex.get() + direction, sessions.length))
87
+ revealOverlay()
88
+
89
+ return null
90
+ }
91
+
92
+ pendingBrowse = true
93
+ scheduleReveal()
94
+
95
+ return sessions[nextIndex]?.id ?? null
96
+ }
97
+
98
+ export const highlightedSessionId = (): string | null =>
99
+ $switcherSessions.get()[$switcherIndex.get()]?.id ?? null
100
+
101
+ export const slotSessionId = (slot: number): string | null =>
102
+ ($switcherOpen.get() || pendingBrowse ? $switcherSessions.get() : $sessions.get())[slot - 1]?.id ?? null
103
+
104
+ export function closeSwitcher(): void {
105
+ closedAt = Date.now()
106
+ clearRevealTimer()
107
+ pendingBrowse = false
108
+ tabHeld = false
109
+ $switcherOpen.set(false)
110
+ }
111
+
112
+ export function commitOnCtrlUp(): string | null {
113
+ clearRevealTimer()
114
+ pendingBrowse = false
115
+
116
+ if (!$switcherOpen.get()) {
117
+ return null
118
+ }
119
+
120
+ const target = highlightedSessionId()
121
+ closeSwitcher()
122
+
123
+ return target
124
+ }
125
+
126
+ export const switcherJustClosed = (): boolean => Date.now() - closedAt < 400
127
+
128
+ export const switcherActive = (): boolean => $switcherOpen.get() || pendingBrowse
@@ -0,0 +1,25 @@
1
+ // Cross-window session-list sync. Each desktop window is its own renderer
2
+ // process with its own gateway socket and session store, so a mutation in one
3
+ // (e.g. a new chat started in the compact pop-out) never reaches another
4
+ // window. This bus pings every window to re-pull the shared session list; the
5
+ // data already lives in the backend, the other window just doesn't know to look.
6
+ const CHANNEL = 'nastech:sessions'
7
+
8
+ const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL)
9
+
10
+ // A window that mutated the session list (created / titled a chat) tells the
11
+ // others to refresh. A BroadcastChannel never delivers to its own poster, so the
12
+ // caller refreshes locally as it already does.
13
+ export function broadcastSessionsChanged(): void {
14
+ channel?.postMessage(1)
15
+ }
16
+
17
+ export function onSessionsChanged(handler: () => void): () => void {
18
+ if (!channel) {
19
+ return () => {}
20
+ }
21
+
22
+ channel.addEventListener('message', handler)
23
+
24
+ return () => channel.removeEventListener('message', handler)
25
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { SessionInfo } from '@/types/nastech'
4
+
5
+ import { $attentionSessionIds, mergeSessionPage, sessionPinId, setSessionAttention } from './session'
6
+
7
+ const session = (over: Partial<SessionInfo>): SessionInfo => ({
8
+ archived: false,
9
+ cwd: null,
10
+ ended_at: null,
11
+ id: 'live',
12
+ input_tokens: 0,
13
+ is_active: false,
14
+ last_active: 0,
15
+ message_count: 0,
16
+ model: null,
17
+ output_tokens: 0,
18
+ preview: null,
19
+ source: null,
20
+ started_at: 0,
21
+ title: null,
22
+ tool_call_count: 0,
23
+ ...over
24
+ })
25
+
26
+ describe('setSessionAttention', () => {
27
+ it('adds and removes a session id without duplicating it', () => {
28
+ $attentionSessionIds.set([])
29
+
30
+ setSessionAttention('s1', true)
31
+ setSessionAttention('s1', true)
32
+ expect($attentionSessionIds.get()).toEqual(['s1'])
33
+
34
+ setSessionAttention('s2', true)
35
+ expect($attentionSessionIds.get()).toEqual(['s1', 's2'])
36
+
37
+ setSessionAttention('s1', false)
38
+ expect($attentionSessionIds.get()).toEqual(['s2'])
39
+
40
+ $attentionSessionIds.set([])
41
+ })
42
+
43
+ it('ignores empty ids and no-op clears', () => {
44
+ $attentionSessionIds.set([])
45
+
46
+ setSessionAttention(null, true)
47
+ setSessionAttention(undefined, true)
48
+ setSessionAttention('', true)
49
+ setSessionAttention('missing', false)
50
+ expect($attentionSessionIds.get()).toEqual([])
51
+ })
52
+ })
53
+
54
+ describe('sessionPinId', () => {
55
+ it('uses the live id when there is no compression lineage', () => {
56
+ expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')
57
+ })
58
+
59
+ it('uses the lineage root so a pin survives compression', () => {
60
+ // After auto-compression the entry surfaces under a fresh tip id but keeps
61
+ // the original root — pinning on the root keeps the pin stable.
62
+ expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
63
+ })
64
+ })
65
+
66
+ describe('mergeSessionPage', () => {
67
+ it('returns the server page untouched when there is nothing to keep', () => {
68
+ const previous = [session({ id: 'a' }), session({ id: 'b' })]
69
+ const incoming = [session({ id: 'a' })]
70
+
71
+ expect(mergeSessionPage(previous, incoming, [])).toBe(incoming)
72
+ })
73
+
74
+ it('keeps a still-working session the server omitted', () => {
75
+ // Repro of the disappearing-sessions bug: A finished and is returned by the
76
+ // server, but B and C are mid-first-response (message_count 0 in the DB) so
77
+ // listSessions(min_messages=1) skips them. They must survive the refresh.
78
+ const previous = [session({ id: 'c' }), session({ id: 'b' }), session({ id: 'a' })]
79
+ const incoming = [session({ id: 'a', message_count: 2 })]
80
+
81
+ const merged = mergeSessionPage(previous, incoming, ['b', 'c'])
82
+
83
+ expect(merged.map(s => s.id)).toEqual(['c', 'b', 'a'])
84
+ // The finished session comes from the fresh server payload, not the stale
85
+ // optimistic copy.
86
+ expect(merged.find(s => s.id === 'a')?.message_count).toBe(2)
87
+ })
88
+
89
+ it('does not duplicate a working session the server already returned', () => {
90
+ const previous = [session({ id: 'b' }), session({ id: 'a' })]
91
+ const incoming = [session({ id: 'b', message_count: 4 }), session({ id: 'a' })]
92
+
93
+ const merged = mergeSessionPage(previous, incoming, ['b'])
94
+
95
+ expect(merged.map(s => s.id)).toEqual(['b', 'a'])
96
+ expect(merged.find(s => s.id === 'b')?.message_count).toBe(4)
97
+ })
98
+
99
+ it('never resurrects a session the server dropped that is not in the keep set', () => {
100
+ // A deleted/archived session is removed from `previous` optimistically and
101
+ // is not in the keep set, so it must stay gone after a refresh.
102
+ const previous = [session({ id: 'b' }), session({ id: 'gone' })]
103
+ const incoming = [session({ id: 'b' })]
104
+
105
+ expect(mergeSessionPage(previous, incoming, ['b']).map(s => s.id)).toEqual(['b'])
106
+ })
107
+
108
+ it('keeps a pinned session that has aged off the recent page', () => {
109
+ // Repro of "loses pins until you refresh": a pinned chat falls off the
110
+ // most-recent page, so the server stops returning it. A hard replace would
111
+ // evict it and the Pinned section would go empty. The keep set (which
112
+ // carries pinned ids) must hold it in memory.
113
+ const previous = [session({ id: 'recent' }), session({ id: 'pinned' })]
114
+ const incoming = [session({ id: 'recent' })]
115
+
116
+ const merged = mergeSessionPage(previous, incoming, ['pinned'])
117
+
118
+ expect(merged.map(s => s.id)).toEqual(['pinned', 'recent'])
119
+ })
120
+
121
+ it('keeps a pinned session matched by its lineage root after compression', () => {
122
+ // The pin is stored on the lineage-root id, but the loaded row surfaces
123
+ // under its live compression tip. Matching on _lineage_root_id keeps it.
124
+ const previous = [session({ id: 'tip', _lineage_root_id: 'root' })]
125
+ const incoming = [session({ id: 'other' })]
126
+
127
+ const merged = mergeSessionPage(previous, incoming, ['root'])
128
+
129
+ expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
130
+ })
131
+ })
@@ -0,0 +1,255 @@
1
+ import { atom } from 'nanostores'
2
+
3
+ import type { ContextSuggestion } from '@/app/types'
4
+ import type { NasTechConnection } from '@/global'
5
+ import type { ChatMessage } from '@/lib/chat-messages'
6
+ import { persistString, storedString } from '@/lib/storage'
7
+ import type { SessionInfo, UsageStats } from '@/types/nastech'
8
+
9
+ type Updater<T> = T | ((current: T) => T)
10
+
11
+ const WORKSPACE_CWD_KEY = 'NASTECH.desktop.workspace-cwd'
12
+
13
+ export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
14
+
15
+ interface AppAtom<T> {
16
+ get: () => T
17
+ set: (value: T) => void
18
+ }
19
+
20
+ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
21
+ store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
22
+ }
23
+
24
+ /** Durable id for pinning. Auto-compression rotates a conversation's session
25
+ * id (root -> continuation tip), so pins keyed on the live id evaporate. The
26
+ * lineage root is stable across every compression, so we pin on that. */
27
+ export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
28
+ session._lineage_root_id ?? session.id
29
+
30
+ /** Merge a fresh server session page into the in-memory list, keeping any
31
+ * row the server omitted that we still want visible — both still-"working"
32
+ * sessions and pinned sessions.
33
+ *
34
+ * Two reasons the server drops a row we must keep:
35
+ *
36
+ * 1. A brand-new session's first user message isn't flushed to the SessionDB
37
+ * until its turn is persisted, so `listSessions(min_messages=1)` skips
38
+ * sessions that are mid-first-response. Because every `message.complete`
39
+ * triggers a full refresh, a hard replace makes concurrent new chats vanish
40
+ * the instant any one of them finishes.
41
+ * 2. The sidebar lists only the most-recent page (`SIDEBAR_SESSIONS_PAGE_SIZE`)
42
+ * ordered by activity. A pinned conversation that hasn't been touched in a
43
+ * while falls off that page, so a hard replace silently evicts it from the
44
+ * in-memory list — and because the Pinned section resolves pins against
45
+ * that list, the pin "disappears until you refresh".
46
+ *
47
+ * `keepIds` carries both the working set and the pinned set. Pins are stored
48
+ * on the durable lineage-root id (see {@link sessionPinId}), while the loaded
49
+ * row surfaces under its live compression tip, so we match a survivor by
50
+ * either its live `id` or its `_lineage_root_id`. Optimistic deletes/archives
51
+ * drop the row from `previous` (and unpin it), so a removed session can't be
52
+ * resurrected here. */
53
+ export function mergeSessionPage(
54
+ previous: SessionInfo[],
55
+ incoming: SessionInfo[],
56
+ keepIds: Iterable<string>
57
+ ): SessionInfo[] {
58
+ const keep = keepIds instanceof Set ? keepIds : new Set(keepIds)
59
+
60
+ if (keep.size === 0) {
61
+ return incoming
62
+ }
63
+
64
+ const incomingIds = new Set(incoming.map(session => session.id))
65
+
66
+ const survivors = previous.filter(
67
+ session =>
68
+ !incomingIds.has(session.id) &&
69
+ (keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
70
+ )
71
+
72
+ return survivors.length ? [...survivors, ...incoming] : incoming
73
+ }
74
+
75
+ export const $connection = atom<NasTechConnection | null>(null)
76
+ export const $gatewayState = atom('idle')
77
+ export const $sessions = atom<SessionInfo[]>([])
78
+ export const $sessionsTotal = atom<number>(0)
79
+ // Cron-job sessions (source === 'cron') are fetched as their own list so the
80
+ // scheduler's always-newest sessions never crowd recents out of the page
81
+ // budget. Powers the collapsed "Cron jobs" sidebar section.
82
+ export const $cronSessions = atom<SessionInfo[]>([])
83
+ // Max cron sessions fetched for the sidebar section (single bounded page). When
84
+ // the fetch returns exactly this many rows we know more exist, so the section
85
+ // badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
86
+ // share one source of truth without a circular import.
87
+ export const CRON_SECTION_LIMIT = 50
88
+ // Listable conversation count per profile (children excluded), keyed by profile
89
+ // name. Lets the sidebar scope its "Load more" footer to the active profile so a
90
+ // huge default profile doesn't keep "Load more" visible while browsing a small
91
+ // one. Empty for single-profile users (fall back to $sessionsTotal).
92
+ export const $sessionProfileTotals = atom<Record<string, number>>({})
93
+ export const $sessionsLoading = atom(true)
94
+ export const $workingSessionIds = atom<string[]>([])
95
+ export const $activeSessionId = atom<string | null>(null)
96
+ export const $selectedStoredSessionId = atom<string | null>(null)
97
+ export const $messages = atom<ChatMessage[]>([])
98
+ export const $freshDraftReady = atom(false)
99
+ export const $busy = atom(false)
100
+ export const $awaitingResponse = atom(false)
101
+ export const $currentModel = atom('')
102
+ export const $currentProvider = atom('')
103
+ export const $currentReasoningEffort = atom('')
104
+ export const $currentServiceTier = atom('')
105
+ export const $currentFastMode = atom(false)
106
+ // Effective approval-bypass state mirrored from the gateway (session.info).
107
+ // Persistence lives in the backend config (approvals.mode), so this is a plain
108
+ // reflection of the truth the gateway reports rather than its own store.
109
+ export const $yoloActive = atom(false)
110
+ export const $currentCwd = atom(getRememberedWorkspaceCwd())
111
+ export const $currentBranch = atom('')
112
+ export const $currentUsage = atom<UsageStats>({
113
+ calls: 0,
114
+ input: 0,
115
+ output: 0,
116
+ total: 0
117
+ })
118
+ export const $sessionStartedAt = atom<number | null>(null)
119
+ export const $turnStartedAt = atom<number | null>(null)
120
+ export const $introPersonality = atom('')
121
+ export const $currentPersonality = atom('')
122
+ export const $availablePersonalities = atom<string[]>([])
123
+ export const $introSeed = atom(0)
124
+ export const $contextSuggestions = atom<ContextSuggestion[]>([])
125
+ export const $modelPickerOpen = atom(false)
126
+
127
+ export const setConnection = (next: Updater<NasTechConnection | null>) => updateAtom($connection, next)
128
+ export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
129
+ export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
130
+ export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
131
+ export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
132
+ export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
133
+ updateAtom($sessionProfileTotals, next)
134
+ export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
135
+ export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
136
+ export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
137
+ export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
138
+ export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
139
+ export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
140
+ export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
141
+ export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
142
+ export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
143
+ export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next)
144
+ export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
145
+ export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
146
+ export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
147
+ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next)
148
+
149
+ export const setCurrentCwd = (next: Updater<string>) => {
150
+ updateAtom($currentCwd, next)
151
+ // Keep localStorage in sync with the atom: a real folder is remembered, an
152
+ // empty cwd clears the key (|| null → removeItem).
153
+ persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
154
+ }
155
+
156
+ export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
157
+ export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
158
+ export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
159
+ export const setTurnStartedAt = (next: Updater<number | null>) => updateAtom($turnStartedAt, next)
160
+ export const setIntroPersonality = (next: Updater<string>) => updateAtom($introPersonality, next)
161
+ export const setCurrentPersonality = (next: Updater<string>) => updateAtom($currentPersonality, next)
162
+ export const setAvailablePersonalities = (next: Updater<string[]>) => updateAtom($availablePersonalities, next)
163
+ export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, next)
164
+ export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
165
+ export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
166
+
167
+ // Watchdog tracking — when does a "working" session count as stuck?
168
+ // Long-running tool calls (LLM inference, long shell commands, web fetches)
169
+ // can take a few minutes legitimately. We allow 8 minutes of complete
170
+ // silence on the stream before clearing the working flag; in practice this
171
+ // catches gateway hangs and dropped streams without false-positive-clearing
172
+ // real long turns.
173
+ const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
174
+ const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
175
+
176
+ function armSessionWatchdog(sessionId: string) {
177
+ const existing = sessionWatchdogTimers.get(sessionId)
178
+
179
+ if (existing) {
180
+ clearTimeout(existing)
181
+ }
182
+
183
+ const timer = setTimeout(() => {
184
+ sessionWatchdogTimers.delete(sessionId)
185
+
186
+ // Re-check the latest state at fire-time. If the user already navigated
187
+ // away or the session genuinely finished, the timer is a no-op.
188
+ if ($workingSessionIds.get().includes(sessionId)) {
189
+ setWorkingSessionIds(current => current.filter(id => id !== sessionId))
190
+ }
191
+ }, SESSION_WATCHDOG_TIMEOUT_MS)
192
+
193
+ sessionWatchdogTimers.set(sessionId, timer)
194
+ }
195
+
196
+ function clearSessionWatchdog(sessionId: string) {
197
+ const existing = sessionWatchdogTimers.get(sessionId)
198
+
199
+ if (existing) {
200
+ clearTimeout(existing)
201
+ sessionWatchdogTimers.delete(sessionId)
202
+ }
203
+ }
204
+
205
+ /** Call when a streaming event for a session lands. Refreshes the watchdog
206
+ * so the session keeps its "working" status as long as data keeps coming. */
207
+ export function noteSessionActivity(sessionId: string | null | undefined) {
208
+ if (!sessionId || !$workingSessionIds.get().includes(sessionId)) {
209
+ return
210
+ }
211
+
212
+ armSessionWatchdog(sessionId)
213
+ }
214
+
215
+ // Toggle an id's membership in a string-set atom, no-op when unchanged (keeps
216
+ // the same array reference so subscribers don't churn).
217
+ const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on: boolean) =>
218
+ set(current => {
219
+ const present = current.includes(id)
220
+
221
+ if (on) {
222
+ return present ? current : [...current, id]
223
+ }
224
+
225
+ return present ? current.filter(x => x !== id) : current
226
+ })
227
+
228
+ // Stored session ids with a blocking prompt (clarify) waiting on the user.
229
+ // Separate from $workingSessionIds: a session can be "working" (turn running)
230
+ // AND need input. The sidebar row reads this for a persistent indicator that,
231
+ // unlike a toast, survives window blur / alt-tab.
232
+ export const $attentionSessionIds = atom<string[]>([])
233
+ export const setAttentionSessionIds = (next: Updater<string[]>) => updateAtom($attentionSessionIds, next)
234
+
235
+ export function setSessionAttention(sessionId: string | null | undefined, needsInput: boolean) {
236
+ if (sessionId) {
237
+ toggleMembership(setAttentionSessionIds, sessionId, needsInput)
238
+ }
239
+ }
240
+
241
+ export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
242
+ if (!sessionId) {
243
+ return
244
+ }
245
+
246
+ toggleMembership(setWorkingSessionIds, sessionId, working)
247
+
248
+ // Bookend the watchdog: arm on enter, disarm on leave. A later
249
+ // noteSessionActivity() from a streaming event refreshes the timer.
250
+ if (working) {
251
+ armSessionWatchdog(sessionId)
252
+ } else {
253
+ clearSessionWatchdog(sessionId)
254
+ }
255
+ }