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,381 @@
1
+ # Profiling renderer typing lag
2
+
3
+ Workflow for empirically measuring (and fixing) typing/submit lag in the
4
+ desktop chat composer.
5
+
6
+ ## Quick boot for profiling
7
+
8
+ Vite 8 + plugin-react 6 has a known issue where the React Fast Refresh
9
+ preamble script isn't injected into `index.html`, so opening Electron at
10
+ `http://127.0.0.1:5174` throws `$RefreshReg$ is not defined` on every TSX
11
+ module and the React tree never mounts. Workaround: run vite with HMR off.
12
+
13
+ ```bash
14
+ # Terminal A — start dev server without HMR
15
+ cd apps/desktop
16
+ node scripts/dev-no-hmr.mjs
17
+
18
+ # Terminal B — start Electron with CDP exposed
19
+ cd apps/desktop
20
+ XCURSOR_SIZE=24 NASTECH_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 \
21
+ ../../node_modules/.bin/electron --remote-debugging-port=9222 .
22
+ ```
23
+
24
+ Terminal C is yours to run the harnesses.
25
+
26
+ ## Harnesses
27
+
28
+ All zero-dep — Node 24 built-in `WebSocket` + `fetch`.
29
+
30
+ ### Typing latency — `measure-latency.mjs`
31
+
32
+ Per-keystroke `keypress → next paint` latency, p50/p90/p99/max.
33
+ Synthesizes keystrokes via `Input.dispatchKeyEvent` so the run is
34
+ reproducible.
35
+
36
+ ```bash
37
+ node apps/desktop/scripts/measure-latency.mjs --chars=120 --cps=20
38
+ ```
39
+
40
+ Anything > 16ms is a dropped frame. On a freshly-loaded session
41
+ (`scripts/click-session.mjs 'Phaser particle'`) we currently see:
42
+
43
+ | | unpatched | patched |
44
+ |---|---|---|
45
+ | p50 paint | 1.9 ms | 2.0 ms |
46
+ | p90 paint | 3.3 ms | 13.7 ms |
47
+ | p99 paint | 16.7 ms | 15.2 ms |
48
+ | max paint | 20.5 ms | 30.4 ms |
49
+ | >16ms drops | 2/120 | 1/120 |
50
+
51
+ Roughly even on a quick session — patches don't fix typing latency
52
+ under benign synthetic conditions because the existing baseline is
53
+ already snappy on synthetic input. The real wins are in the leak counters
54
+ (see below). If the user reports typing jank, capture a profile + heap
55
+ diff during their actual usage and compare against the synthetic baseline
56
+ to identify what condition (long thread, popover open, paste, etc.)
57
+ makes the path slow.
58
+
59
+ ### Leak counters — `leak-typing.mjs`
60
+
61
+ Types N chars per round, clears, force-GCs, captures
62
+ `Performance.getMetrics` deltas. Reveals leaked event listeners, heap
63
+ drift, document node growth, and forced-layout counts.
64
+
65
+ ```bash
66
+ # After clicking into a real session (e.g. via click-session.mjs):
67
+ node apps/desktop/scripts/leak-typing.mjs --rounds=8 --chars=200 --cps=50
68
+ ```
69
+
70
+ **Real-session numbers (Phaser thread, 8 rounds × 200 chars):**
71
+
72
+ | | unpatched (HEAD~2) | patched (HEAD) |
73
+ |---|---|---|
74
+ | jsListeners growth/round | +0 | +0 |
75
+ | DOM nodes growth/round | +0 | +0 |
76
+ | heap growth/round | ~0 (V8 housekeeping) | ~0 |
77
+ | **forced layouts/char** | **7.02** | **2.35** (3× fewer) |
78
+
79
+ The forced-layout count is the load-bearing number — typing into a real
80
+ session was triggering ~7 layouts per character on the unpatched build
81
+ (scrollHeight reads + per-px CSS var writes + FadeText scrollWidth reads
82
+ all stacking up). After the patches it's down to ~2.35/char, which is
83
+ Blink's natural cost for a 1px/char-growing contentEditable and can't
84
+ be lowered further without architectural changes.
85
+
86
+ The initial "+35 listeners/round leak" I called out on the first
87
+ unpatched run turned out to be transient warm-up (popovers initializing,
88
+ etc.); steady-state listener growth was 0 both before and after.
89
+
90
+ ### CPU profile + heap snapshot — `profile-typing.mjs`
91
+
92
+ Records a CPU profile while typing, plus before/after heap snapshots so
93
+ you can do a comparison diff in Chrome DevTools Memory tab.
94
+
95
+ ```bash
96
+ node apps/desktop/scripts/profile-typing.mjs \
97
+ --chars=400 --cps=30 --out=/tmp/NASTECH-typing
98
+ # → /tmp/NASTECH-typing.cpuprofile (open in Chrome DevTools Performance)
99
+ # → /tmp/NASTECH-typing.before.heapsnapshot
100
+ # → /tmp/NASTECH-typing.after.heapsnapshot
101
+ ```
102
+
103
+ Loading the cpuprofile: Chrome DevTools → Performance tab → drag the file
104
+ in, or VS Code → open the `.cpuprofile` directly.
105
+
106
+ For heap diff: Chrome DevTools → Memory → Load snapshot → load "before",
107
+ then Comparison view → load "after". Sort by `# Delta`. Stay alert for
108
+ detached DOM, FiberNodes (unmounted), and listener growth.
109
+
110
+ ## Helpers
111
+
112
+ - `probe-renderer.mjs` — dump page state (URL, composer mounted?, body text)
113
+ - `click-session.mjs <title>` — click a sidebar session by partial title match
114
+ - `reload-renderer.mjs` — force Page.reload via CDP (no HMR available)
115
+ - `dump-state.mjs` — richer state dump (thread message count, sticky session, etc.)
116
+ - `probe-console.mjs` — dump recent console errors / exceptions
117
+
118
+ ## Findings
119
+
120
+ See commit message for `apps/desktop/src/app/chat/composer/index.tsx`
121
+ edits. Three changes:
122
+
123
+ 1. **Per-keystroke `scrollHeight` read removed.** The expansion useEffect
124
+ used to read `editorRef.current.scrollHeight` on every draft change
125
+ (forces synchronous layout). Replaced with a `draft.length > 60`
126
+ heuristic; the ResizeObserver catches anything the heuristic misses.
127
+
128
+ 2. **Bucketed CSS custom-property writes.** `syncComposerMetrics`
129
+ used to `setProperty('--composer-measured-height', height + 'px')`
130
+ on every observed resize, invalidating computed style for the whole
131
+ tree. Now writes only when the height crosses an 8 px bucket, so
132
+ typing in a fixed-height row produces no style invalidation at all.
133
+
134
+ 3. **Removed dead `$composerDraft` → `aui.composer().setText` round-trip.**
135
+ Nothing outside the composer subscribed to `$composerDraft` (verified
136
+ via grep). The two useEffects that pushed draft → store and store →
137
+ composer were pure overhead per keystroke. `reconcileComposerTerminalSelections`
138
+ was also called per keystroke; can be deferred to submit time (it's a
139
+ stale-pruning step, not a correctness one — `terminalContextBlocksFromDraft`
140
+ walks the current text directly at submit and ignores stale labels).
141
+
142
+ 4. **`refreshTrigger` fast-bails when no `@`/`/` in draft.** Previously
143
+ `textBeforeCaret()` did `range.toString()` (O(n)) on every keystroke
144
+ even when no trigger char was present.
145
+
146
+ The biggest win is the listener leak in (3) — without it, each round of
147
+ typing leaked ~35 event listeners until a steady state.
148
+
149
+ ## Submit / TTFT stall (open)
150
+
151
+ User reports a perceived stall *after* Enter, before the assistant starts
152
+ streaming. `scripts/measure-submit.mjs` measures
153
+ `enter → composer-cleared → user-message-rendered → first-paint`. The
154
+ script triggers a real prompt submission, so use it on a throwaway
155
+ session. Not enabled in CI.
156
+
157
+ ## Streaming "5fps" investigation (May 21, 2026)
158
+
159
+ User complaint: "the streaming must bring fps to like 5? lol" — felt
160
+ hitches during assistant streaming on long threads.
161
+
162
+ ### Tooling added
163
+
164
+ - **`src/app/chat/perf-probe.tsx`** — dev-only side-effect import (guarded by
165
+ `import.meta.env.MODE !== 'production'` in `main.tsx`). Attaches two
166
+ helpers to `window`:
167
+ - `__PERF_PROBE__` — React `<Profiler>` recorder. Currently inert because
168
+ Vite is serving the production React build (see "Vite dev-build issue"
169
+ below); kept for when that's fixed.
170
+ - `__PERF_DRIVE__` — synthetic stream driver. Pushes tokens through the
171
+ live `$messages` atom at a fixed cadence, so the assistant-ui runtime,
172
+ incremental repository, Streamdown markdown renderer, and React commit
173
+ pipeline all see the same workload they'd see from a real LLM stream —
174
+ but with no LLM call (and no credit cost).
175
+ - **`scripts/measure-synthetic-stream.mjs`** — drives `__PERF_DRIVE__`,
176
+ records rAF frame intervals, `PerformanceObserver({entryTypes:['longtask']})`
177
+ entries, `MutationObserver` cadence on the live message, and optional
178
+ type-while-streaming keystroke latency.
179
+ - **`scripts/profile-synth-stream.mjs`** — CPU profile during a synthetic
180
+ stream; writes a `.cpuprofile` (open in Chrome DevTools Performance panel)
181
+ and a top-30 self-time table.
182
+ - **`scripts/measure-real-stream.mjs`** — same harness as the synthetic but
183
+ fires a real LLM prompt. Use when you have credits and want to confirm
184
+ the synthetic predictions hold.
185
+ - **`scripts/profile-real-stream.mjs`** — CPU profile over the duration of
186
+ a real LLM stream.
187
+
188
+ Helpers: `scripts/eval.mjs` (one-shot CDP eval), `scripts/reload.mjs`
189
+ (hard reload renderer over CDP).
190
+
191
+ ### Findings
192
+
193
+ Measured on the Cloud Shadows session (7 turns, ~11k px scrollHeight) and
194
+ the 34 MB session `session_20260514_215353_fe0ac8.json` (110 FadeText
195
+ instances, lots of historical tool calls).
196
+
197
+ | metric | Cloud Shadows | 34 MB session |
198
+ |---|---|---|
199
+ | avgFps (60 tok/sec, 5s) | 60.0 | 58.6 |
200
+ | frame p50 / p95 / p99 (ms) | 16.7 / 18.0 / 21.1 | 16.6 / 25.6 / 31.4 |
201
+ | max frame (ms) | 31.1 | 97-127 (varies) |
202
+ | longtasks per 5s window | 0 | 1-2, 75-127 ms |
203
+ | type-while-stream p95 latency (ms) | 17 | — |
204
+
205
+ A single real-LLM stream on Cloud Shadows (gpt-4o-mini, 39s window) saw
206
+ 12 longtasks totalling 1.26 s — same cadence the synthetic predicted
207
+ (~1 hitch per 3.25 s, max 123 ms). So the **synthetic stream is a faithful
208
+ proxy for the real one** and is fine for iterating on fixes without paying
209
+ for tokens.
210
+
211
+ ### CPU profile during streaming (synthetic, markdown content)
212
+
213
+ Top self-time costs (5 s window, 400 tokens at 125 tok/s, markdown chunks):
214
+
215
+ | ms (self) | function | source |
216
+ |---|---|---|
217
+ | 260 | `bn$1` | `chunk-BO2N…js:20003` (micromark tokenize) |
218
+ | 249 | `m$1` | `chunk-BO2N…js:19949` (micromark) |
219
+ | 128 | `compile` | `chunk-BO2N…js:21884` (mdast → hast compile) |
220
+ | 73 | FadeText body | `components/ui/fade-text.tsx` |
221
+ | 62 | `parser` | `chunk-BO2N…js:22680` |
222
+ | 49 | `fromThreadMessageLike` | `@assistant-ui/internal` |
223
+
224
+ That `chunk-BO2N2NFS` is the vendored bundle containing `micromark`,
225
+ `mdast-util-from-markdown`, `mdast-util-to-hast`, `rehype-raw`,
226
+ `hast-util-sanitize`, etc. — i.e. **Streamdown's markdown pipeline,
227
+ re-parsing the entire growing assistant message on every token append**.
228
+ Cost scales linearly with message length.
229
+
230
+ Compare plain-text (no markdown) — the `chunk-BO2N…` entries drop out
231
+ of the top 30 entirely; total work per 5 s window halves.
232
+
233
+ ### Fix landed: `FadeText` memo
234
+
235
+ `FadeText` is used in `tool-fallback.tsx` (110 instances on a tool-heavy
236
+ thread). Before: each parent re-render during streaming triggered a
237
+ `useEffect([children])` that forced a `scrollWidth` layout read — even
238
+ when the title text was unchanged. The `useResizeObserver` already covers
239
+ the genuine resize case, so the effect was strictly redundant.
240
+
241
+ After: wrapped in `React.memo` with a custom comparator that compares
242
+ `children` (scalar fast-path), `className`, `fadeWidth`, and `style`
243
+ field-by-field. Verified via temporary render counter:
244
+ **122 renders during a 2 s synthetic stream vs ~11 000 without memo**
245
+ (110 instances × ~100 stream updates). Doesn't move the longtask needle
246
+ on its own — Streamdown dwarfs it — but eliminates a class of forced
247
+ layouts and removes a steady CPU floor.
248
+
249
+ ### Also landed: `MarkdownText` plugins memo + upstream flush floor
250
+
251
+ Two smaller follow-ups in the same investigation:
252
+
253
+ 1. **`MarkdownText` `plugins` object useMemo'd.** The inline
254
+ `plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}`
255
+ was constructing a new object on every render, which churns
256
+ `<Streamdown>`'s outer memo and forces its internal `rehypePlugins` /
257
+ `remarkPlugins` arrays to rebuild. CPU profile after the change shows
258
+ `parser` self-time dropping out of the top 10, `compile` cut roughly
259
+ in half, and `bn$1` / `m$1` (micromark internals) dropping off the
260
+ top entries.
261
+
262
+ 2. **`use-message-stream.scheduleDeltaFlush` got a real minimum floor.**
263
+ Previously the rAF-only path effectively meant "at most one flush per
264
+ frame," but at typical LLM token rates of 30-80 tok/sec each token
265
+ arrives slower than rAF cadence and gets its own React commit. With
266
+ `STREAM_DELTA_FLUSH_MS = 33` (two frames) and a `lastFlushAt`-tracked
267
+ floor, slower streams now coalesce ~2 tokens per commit, halving
268
+ markdown re-parses. React's auto-batching already covers part of this
269
+ probabilistically; the floor makes the batching deterministic so the
270
+ max-longtask number tightens up.
271
+
272
+ A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
273
+ (3 trials each):
274
+
275
+ | | avgFps | p99 frame | LTs/5s | max LT | mutations |
276
+ |---|---|---|---|---|---|
277
+ | no throttle | 54.0 | 38 ms | 2.0 | 145 ms | varies (2-112) |
278
+ | 33 ms throttle | 54.3 | 41 ms | 1.7 | 110 ms | ~135 |
279
+
280
+ Modest. `inter-mutation` p50 tightens from 22-28 ms to a clean 33 ms,
281
+ which is what you'd expect from a deterministic floor.
282
+
283
+ ### Also landed: `useDeferredValue` at the streamdown-text boundary
284
+
285
+ The longtask CPU was unavoidable inside the block-memo pattern — the live
286
+ tail re-parses every commit, scales linearly with current length, and
287
+ nothing about Streamdown's architecture changes that without forking. The
288
+ fix is to stop having that work *block* the main thread.
289
+
290
+ `<DeferStreamingText>` in `markdown-text.tsx` is a 12-line wrapper that
291
+ reads the message-part state via `useMessagePartText`, runs it through
292
+ `useDeferredValue`, and re-publishes via assistant-ui's
293
+ `<TextMessagePartProvider>`. The inner `StreamdownTextPrimitive` reads the
294
+ deferred value through the normal `useMessagePartText` hook — no fork,
295
+ no internal-path imports, fully on the assistant-ui public API.
296
+
297
+ What React's concurrent scheduler now does:
298
+
299
+ - When a new token arrives mid-render, the in-flight deferred render
300
+ is abandoned and a fresh one starts with the latest text.
301
+ - When the main thread has urgent work (typing, scroll, layout), the
302
+ Streamdown render gets deprioritized — input stays responsive even
303
+ while a 100 ms parse is queued.
304
+
305
+ Streamdown already uses `useTransition` internally for its block-array
306
+ setState; `useDeferredValue` here just lifts the deferral all the way up
307
+ to the consumer text boundary, so the whole pipeline — preprocess,
308
+ block split, repair, parse, render — runs at low priority during streaming.
309
+ This is the industry-standard approach (see
310
+ [Streamdown architecture analysis](https://tigerabrodi.blog/how-to-build-a-performant-ai-markdown-renderer)
311
+ and Chrome's [LLM-response render best practices](https://developer.chrome.google.cn/docs/ai/render-llm-responses)).
312
+
313
+ A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
314
+ (four trials each, prod-throttle (33 ms) on for both):
315
+
316
+ | | avgFps | p99 frame | LTs / 5 s | max LT | typing p95 |
317
+ |---|---|---|---|---|---|
318
+ | pre-defer | 54.3 | 41 ms | 1.7 | 110 ms | ~17 ms |
319
+ | **post-defer** | **58.5** | **31 ms** | 2.0 | 117 ms | 14-18 ms |
320
+
321
+ Longtask count and max LT are unchanged — `useDeferredValue` doesn't
322
+ reduce CPU, only its priority. The avgFps lift and p99 frame drop are
323
+ the proof that the existing CPU is no longer blocking 60 fps cadence:
324
+ when React can defer the parse, frames stay clean. One particularly
325
+ clean run logged **MUTATIONS=0** — React skipped every intermediate
326
+ text state and only committed the final one, the textbook
327
+ useDeferredValue behaviour.
328
+
329
+ ### Not fixed: Streamdown markdown re-parse cost (the elephant)
330
+
331
+ Total CPU spent in micromark/mdast/hast pipeline per 5 s window is still
332
+ the same ~700 ms. With `useDeferredValue` that work no longer blocks
333
+ input, but if you watch a CPU profile you'll see the same hot functions
334
+ (`Tn$1`, `bn$1`, `m$1`, `parser`, `compile`).
335
+
336
+ The path to actually *reduce* that cost (not just defer it) is to
337
+ replace the parser with a state machine like
338
+ [Flowdown](https://github.com/Atomics-hub/flowdown) — process each
339
+ character exactly once, emit DOM ops directly, no re-parse of the prefix
340
+ on every token. Claimed ~2,000× over `marked`. Trades: not a
341
+ `react-markdown`-compatible API, no rehype security pipeline, would
342
+ require replacing Streamdown wholesale. Worth investigating only if
343
+ even the deferred work shows up in user-perceptible ways (e.g.
344
+ trackpad-scrolling a stream-in-progress stutters).
345
+
346
+ The synthetic harness now mirrors the real upstream pipeline via the
347
+ `flushMinMs` option in `__PERF_DRIVE__.stream({ flushMinMs: 33 })`, so
348
+ future Streamdown / Flowdown experiments can A/B without LLM credit cost.
349
+ The synthetic numbers tracked the one real-LLM run we caught within
350
+ noise, so it's a reliable proxy.
351
+
352
+ Possible approaches (none implemented here):
353
+
354
+ 1. **Coalesce/throttle Streamdown updates** — render at most every 32 ms
355
+ instead of every set-state. Reduces parses but doesn't reduce
356
+ per-parse cost; trades latency for smoothness.
357
+ 2. **Memoize per-prefix** — diff the new text against the prior parsed
358
+ version; only re-parse the changed suffix.
359
+ 3. **Render in stable segments** — close-form historical paragraphs as
360
+ immutable React nodes; only the live tail goes through markdown each
361
+ token. Probably the highest-impact change but requires forking or
362
+ patching `@assistant-ui/react-streamdown`.
363
+ 4. **Move parsing to a Web Worker** — main thread no longer blocks on
364
+ markdown. Largest surgery; requires double-buffered hast.
365
+
366
+ ### Vite dev-build issue (separate)
367
+
368
+ `http://127.0.0.1:5174/node_modules/.vite/deps/react.js` resolves to
369
+ `react/cjs/react.production.js`, and `react-dom_client.js` →
370
+ `react-dom-client.production.js`. As a result:
371
+
372
+ - `<React.Profiler>` `onRender` is never called (production build is a
373
+ no-op).
374
+ - `import.meta.env.DEV` is `false`, `PROD` is `true` even under `vite dev`
375
+ (hence `MODE !== 'production'` as the workaround in `main.tsx`).
376
+ - All the React 19 dev-only warnings/devtools backend hooks are absent.
377
+
378
+ Root cause likely sits in `vite.config.ts` aliasing + dedupe + Vite 8's
379
+ new `optimizeDeps` defaults. Worth a separate fix pass — when it's
380
+ resolved, the `<PerfProbe>` blocks in `perf-probe.tsx` become useful
381
+ (per-id commit timings) instead of inert.
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ // Profile typing lag in the Electron renderer by:
3
+ // 1. Connecting to a running renderer via CDP (--remote-debugging-port=9222)
4
+ // 2. Focusing the composer contentEditable
5
+ // 3. Starting CPU profile + heap snapshot
6
+ // 4. Synthesizing keystrokes via Input.dispatchKeyEvent (so the run is
7
+ // reproducible, no human-typing variance)
8
+ // 5. Stopping the profile + capturing a second heap snapshot
9
+ // 6. Saving .cpuprofile + .heapsnapshot
10
+ //
11
+ // Usage:
12
+ // node apps/desktop/scripts/profile-typing.mjs
13
+ // [--port=9222] [--out=/tmp/nastech-typing]
14
+ // [--chars=400] # how many characters to type
15
+ // [--cps=30] # keystrokes per second
16
+ // [--text="..."] # override generated text
17
+ // [--no-heap] # skip heap snapshots
18
+ // [--seconds=N] # idle-record for N seconds instead of typing
19
+ //
20
+ // Zero deps — uses Node 24's global WebSocket + fetch.
21
+
22
+ import { writeFileSync } from 'node:fs'
23
+
24
+ const args = Object.fromEntries(
25
+ process.argv.slice(2).flatMap(s => {
26
+ const m = s.match(/^--([^=]+)(?:=(.*))?$/)
27
+ return m ? [[m[1], m[2] ?? true]] : []
28
+ })
29
+ )
30
+
31
+ const PORT = Number(args.port ?? 9222)
32
+ const OUT = String(args.out ?? `/tmp/nastech-typing-${Date.now()}`)
33
+ const CHARS = Number(args.chars ?? 400)
34
+ const CPS = Number(args.cps ?? 30)
35
+ const HEAP = args['no-heap'] ? false : true
36
+ const IDLE_SECONDS = args.seconds ? Number(args.seconds) : null
37
+ const CUSTOM_TEXT = args.text === undefined || args.text === true ? null : String(args.text)
38
+
39
+ const log = (...m) => console.log('[profile]', ...m)
40
+ const banner = m => console.log(`\n========== ${m} ==========`)
41
+
42
+ async function pickRenderer() {
43
+ const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
44
+ const pages = list.filter(t => t.type === 'page' && t.url.startsWith('http'))
45
+ if (!pages.length) {
46
+ console.error('No renderer page. Targets:')
47
+ list.forEach(t => console.error(' ', t.type, t.url))
48
+ process.exit(2)
49
+ }
50
+ return pages[0]
51
+ }
52
+
53
+ function connect(url) {
54
+ return new Promise((resolve, reject) => {
55
+ const ws = new WebSocket(url)
56
+ let id = 0
57
+ const pending = new Map()
58
+ const events = new Map()
59
+ ws.addEventListener('open', () =>
60
+ resolve({
61
+ send(method, params = {}) {
62
+ const myId = ++id
63
+ ws.send(JSON.stringify({ id: myId, method, params }))
64
+ return new Promise((res, rej) => pending.set(myId, { res, rej }))
65
+ },
66
+ on(method, h) {
67
+ if (!events.has(method)) events.set(method, [])
68
+ events.get(method).push(h)
69
+ },
70
+ close: () => ws.close()
71
+ })
72
+ )
73
+ ws.addEventListener('error', reject)
74
+ ws.addEventListener('message', ev => {
75
+ const txt = typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')
76
+ const m = JSON.parse(txt)
77
+ if (m.id != null) {
78
+ const p = pending.get(m.id)
79
+ if (!p) return
80
+ pending.delete(m.id)
81
+ m.error ? p.rej(new Error(m.error.message)) : p.res(m.result)
82
+ } else if (m.method) {
83
+ ;(events.get(m.method) ?? []).forEach(h => h(m.params))
84
+ }
85
+ })
86
+ })
87
+ }
88
+
89
+ async function captureHeap(cdp, path) {
90
+ log(`heap snapshot → ${path}`)
91
+ const chunks = []
92
+ cdp.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => chunks.push(chunk))
93
+ await cdp.send('HeapProfiler.enable')
94
+ await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false, captureNumericValue: true })
95
+ writeFileSync(path, chunks.join(''))
96
+ log(` ${(Buffer.byteLength(chunks.join(''), 'utf8') / 1024 / 1024).toFixed(1)} MB`)
97
+ }
98
+
99
+ async function focusComposer(cdp) {
100
+ // Focus the rich-input contentEditable. RICH_INPUT_SLOT is the data-slot
101
+ // value used by the composer's editable div. If focus fails (no composer
102
+ // mounted yet — disabled state, etc.) the script logs and continues; the
103
+ // profile will still show idle behavior.
104
+ const result = await cdp.send('Runtime.evaluate', {
105
+ expression: `
106
+ (() => {
107
+ const el = document.querySelector('[data-slot="composer-rich-input"]')
108
+ if (!el) return { ok: false, reason: 'composer-rich-input not found' }
109
+ el.focus()
110
+ // place caret at end
111
+ const range = document.createRange()
112
+ range.selectNodeContents(el)
113
+ range.collapse(false)
114
+ const sel = window.getSelection()
115
+ sel.removeAllRanges()
116
+ sel.addRange(range)
117
+ return { ok: true, text: el.innerText.length }
118
+ })()
119
+ `,
120
+ returnByValue: true
121
+ })
122
+ if (!result.result.value?.ok) {
123
+ log(`focus failed: ${result.result.value?.reason ?? 'unknown'}`)
124
+ return false
125
+ }
126
+ log(`composer focused (existing text length: ${result.result.value.text})`)
127
+ return true
128
+ }
129
+
130
+ function genText(n) {
131
+ const lorem =
132
+ 'the quick brown fox jumps over the lazy dog while the agent thinks really hard about why typing into this composer feels like wading through molasses on a hot afternoon '
133
+ let s = ''
134
+ while (s.length < n) s += lorem
135
+ return s.slice(0, n)
136
+ }
137
+
138
+ async function dispatchChar(cdp, ch) {
139
+ // For printable chars, char + keypress is enough — Electron treats it as text input
140
+ // and the contentEditable input event fires. For Enter / Space we could add
141
+ // specials; this run is one long line.
142
+ await cdp.send('Input.dispatchKeyEvent', {
143
+ type: 'char',
144
+ text: ch,
145
+ unmodifiedText: ch
146
+ })
147
+ }
148
+
149
+ async function typeText(cdp, text, cps) {
150
+ const intervalMs = Math.max(1, Math.round(1000 / cps))
151
+ const start = Date.now()
152
+ for (let i = 0; i < text.length; i++) {
153
+ await dispatchChar(cdp, text[i])
154
+ // Pace evenly; account for dispatch latency so we don't drift much.
155
+ const expected = start + (i + 1) * intervalMs
156
+ const wait = expected - Date.now()
157
+ if (wait > 0) await new Promise(r => setTimeout(r, wait))
158
+ }
159
+ }
160
+
161
+ async function main() {
162
+ log(`CDP port ${PORT}, out ${OUT}`)
163
+ const target = await pickRenderer()
164
+ log(`target ${target.url}`)
165
+ const cdp = await connect(target.webSocketDebuggerUrl)
166
+ await cdp.send('Runtime.enable')
167
+ await cdp.send('Page.enable')
168
+ await cdp.send('Profiler.enable')
169
+
170
+ // Pre-GC so the cpu profile + heap delta are clean.
171
+ try {
172
+ await cdp.send('HeapProfiler.collectGarbage')
173
+ } catch (e) {
174
+ log('GC skipped:', e.message)
175
+ }
176
+
177
+ if (HEAP) await captureHeap(cdp, `${OUT}.before.heapsnapshot`)
178
+
179
+ // 1ms sampling — fine enough for per-frame React work.
180
+ await cdp.send('Profiler.setSamplingInterval', { interval: 1000 })
181
+
182
+ let typedText = ''
183
+ if (!IDLE_SECONDS) {
184
+ const focused = await focusComposer(cdp)
185
+ if (!focused) {
186
+ log('aborting — composer not focusable. Make sure the app is past the boot screen.')
187
+ cdp.close()
188
+ process.exit(3)
189
+ }
190
+ typedText = CUSTOM_TEXT ?? genText(CHARS)
191
+ }
192
+
193
+ await cdp.send('Profiler.start')
194
+
195
+ if (IDLE_SECONDS) {
196
+ banner(`IDLE recording for ${IDLE_SECONDS}s — DO NOT TOUCH`)
197
+ await new Promise(r => setTimeout(r, IDLE_SECONDS * 1000))
198
+ } else {
199
+ banner(`TYPING ${typedText.length} chars @ ${CPS} cps (≈${(typedText.length / CPS).toFixed(1)}s)`)
200
+ const t0 = Date.now()
201
+ await typeText(cdp, typedText, CPS)
202
+ log(`typing wall time: ${((Date.now() - t0) / 1000).toFixed(2)}s`)
203
+ // Settle frame for trailing React work.
204
+ await new Promise(r => setTimeout(r, 500))
205
+ }
206
+
207
+ banner('STOP — saving profile')
208
+ const { profile } = await cdp.send('Profiler.stop')
209
+ writeFileSync(`${OUT}.cpuprofile`, JSON.stringify(profile))
210
+ log(`cpu profile → ${OUT}.cpuprofile (${(JSON.stringify(profile).length / 1024 / 1024).toFixed(1)} MB)`)
211
+
212
+ if (HEAP) {
213
+ try {
214
+ await cdp.send('HeapProfiler.collectGarbage')
215
+ } catch {}
216
+ await captureHeap(cdp, `${OUT}.after.heapsnapshot`)
217
+ }
218
+
219
+ // Quick triage: top-self-time frames from the profile.
220
+ const top = summarizeProfile(profile)
221
+ banner('TOP SELF-TIME FRAMES')
222
+ for (const row of top.slice(0, 20)) {
223
+ console.log(
224
+ ` ${row.selfMs.toFixed(1).padStart(7)}ms ${row.functionName || '(anonymous)'}` +
225
+ ` ${row.url ? '· ' + row.url.replace(/^.*\/src\//, 'src/').slice(0, 80) : ''}`
226
+ )
227
+ }
228
+ console.log()
229
+ log(`total samples: ${top.totalSamples}, total time: ${(top.totalMs / 1000).toFixed(2)}s`)
230
+
231
+ cdp.close()
232
+ }
233
+
234
+ function summarizeProfile(profile) {
235
+ // Cumulative samples = how many sampling ticks landed on each node.
236
+ // selfMs = own time only, using sampling interval.
237
+ const intervalMs = (profile.endTime - profile.startTime) / 1000 / Math.max(1, profile.samples?.length ?? 1)
238
+ const counts = new Map()
239
+ for (const s of profile.samples ?? []) counts.set(s, (counts.get(s) ?? 0) + 1)
240
+ const rows = profile.nodes.map(n => {
241
+ const self = counts.get(n.id) ?? 0
242
+ return {
243
+ id: n.id,
244
+ functionName: n.callFrame.functionName,
245
+ url: n.callFrame.url,
246
+ lineNumber: n.callFrame.lineNumber,
247
+ selfSamples: self,
248
+ selfMs: self * intervalMs
249
+ }
250
+ })
251
+ rows.sort((a, b) => b.selfSamples - a.selfSamples)
252
+ rows.totalSamples = (profile.samples ?? []).length
253
+ rows.totalMs = ((profile.endTime - profile.startTime) / 1000)
254
+ return rows
255
+ }
256
+
257
+ main().catch(e => {
258
+ console.error('[profile] fatal:', e.stack ?? e.message)
259
+ process.exit(1)
260
+ })
@@ -0,0 +1,25 @@
1
+ // Reload the renderer via CDP so it picks up the latest from Vite.
2
+ const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
3
+ const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
4
+ const ws = new WebSocket(tgt.webSocketDebuggerUrl)
5
+ let id = 0
6
+ const pending = new Map()
7
+ ws.addEventListener('message', ev => {
8
+ const m = JSON.parse(ev.data)
9
+ if (m.id != null && pending.has(m.id)) {
10
+ pending.get(m.id)(m)
11
+ pending.delete(m.id)
12
+ }
13
+ })
14
+ await new Promise(r => ws.addEventListener('open', r))
15
+ const send = (method, params = {}) =>
16
+ new Promise(r => {
17
+ const i = ++id
18
+ pending.set(i, r)
19
+ ws.send(JSON.stringify({ id: i, method, params }))
20
+ })
21
+ await send('Page.enable')
22
+ await send('Page.reload', { ignoreCache: true })
23
+ console.log('reload requested')
24
+ await new Promise(r => setTimeout(r, 200))
25
+ ws.close()