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,1474 @@
1
+ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
2
+ import {
3
+ ActionBarPrimitive,
4
+ BranchPickerPrimitive,
5
+ ComposerPrimitive,
6
+ ErrorPrimitive,
7
+ MessagePrimitive,
8
+ type ToolCallMessagePartProps,
9
+ useAui,
10
+ useAuiState
11
+ } from '@assistant-ui/react'
12
+ import { useStore } from '@nanostores/react'
13
+ import { IconPlayerStopFilled } from '@tabler/icons-react'
14
+ import {
15
+ type ClipboardEvent,
16
+ type ComponentProps,
17
+ type FC,
18
+ type FocusEvent,
19
+ type FormEvent,
20
+ type KeyboardEvent,
21
+ type DragEvent as ReactDragEvent,
22
+ type ReactNode,
23
+ useCallback,
24
+ useEffect,
25
+ useMemo,
26
+ useRef,
27
+ useState
28
+ } from 'react'
29
+
30
+ import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
31
+ import {
32
+ type ComposerInsertMode,
33
+ focusComposerInput,
34
+ markActiveComposer,
35
+ onComposerFocusRequest,
36
+ onComposerInsertRequest
37
+ } from '@/app/chat/composer/focus'
38
+ import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
39
+ import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
40
+ import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs'
41
+ import {
42
+ composerPlainText,
43
+ placeCaretEnd,
44
+ refChipElement,
45
+ renderComposerContents,
46
+ RICH_INPUT_SLOT
47
+ } from '@/app/chat/composer/rich-editor'
48
+ import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
49
+ import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
50
+ import { extractDroppedFiles, NASTECH_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
51
+ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
52
+ import { DirectiveContent, NASTECHDirectiveFormatter } from '@/components/assistant-ui/directive-text'
53
+ import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
54
+ import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
55
+ import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
56
+ import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
57
+ import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
58
+ import { UserMessageText } from '@/components/assistant-ui/user-message-text'
59
+ import { useElapsedSeconds } from '@/components/chat/activity-timer'
60
+ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
61
+ import { DisclosureRow } from '@/components/chat/disclosure-row'
62
+ import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context'
63
+ import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
64
+ import { Intro, type IntroProps } from '@/components/chat/intro'
65
+ import { PreviewAttachment } from '@/components/chat/preview-attachment'
66
+ import { Codicon } from '@/components/ui/codicon'
67
+ import { CopyButton } from '@/components/ui/copy-button'
68
+ import {
69
+ DropdownMenu,
70
+ DropdownMenuContent,
71
+ DropdownMenuItem,
72
+ DropdownMenuLabel,
73
+ DropdownMenuTrigger
74
+ } from '@/components/ui/dropdown-menu'
75
+ import { Loader } from '@/components/ui/loader'
76
+ import type { NasTechGateway } from '@/nastech'
77
+ import { useResizeObserver } from '@/hooks/use-resize-observer'
78
+ import { useI18n } from '@/i18n'
79
+ import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
80
+ import { triggerHaptic } from '@/lib/haptics'
81
+ import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
82
+ import { extractPreviewTargets } from '@/lib/preview-targets'
83
+ import { useEnterAnimation } from '@/lib/use-enter-animation'
84
+ import { cn } from '@/lib/utils'
85
+ import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
86
+ import { notifyError } from '@/store/notifications'
87
+ import { $voicePlayback } from '@/store/voice-playback'
88
+
89
+ type ThreadLoadingState = 'response' | 'session'
90
+
91
+ interface MessageActionProps {
92
+ messageId: string
93
+ messageText: string
94
+ onBranchInNewChat?: (messageId: string) => void
95
+ }
96
+
97
+ let readAloudAudio: HTMLAudioElement | null = null
98
+
99
+ function partText(part: unknown): string {
100
+ if (typeof part === 'string') {
101
+ return part
102
+ }
103
+
104
+ if (!part || typeof part !== 'object') {
105
+ return ''
106
+ }
107
+
108
+ const row = part as { text?: unknown; type?: unknown }
109
+
110
+ return (!row.type || row.type === 'text') && typeof row.text === 'string' ? row.text : ''
111
+ }
112
+
113
+ function messageContentText(content: unknown): string {
114
+ if (typeof content === 'string') {
115
+ return content.trim()
116
+ }
117
+
118
+ return Array.isArray(content) ? content.map(partText).join('').trim() : ''
119
+ }
120
+
121
+ export const Thread: FC<{
122
+ clampToComposer?: boolean
123
+ cwd?: string | null
124
+ gateway?: NasTechGateway | null
125
+ intro?: IntroProps
126
+ loading?: ThreadLoadingState
127
+ onBranchInNewChat?: (messageId: string) => void
128
+ onCancel?: () => Promise<void> | void
129
+ sessionId?: string | null
130
+ sessionKey?: string | null
131
+ }> = ({
132
+ clampToComposer = false,
133
+ cwd = null,
134
+ gateway = null,
135
+ intro,
136
+ loading,
137
+ onBranchInNewChat,
138
+ onCancel,
139
+ sessionId = null,
140
+ sessionKey
141
+ }) => {
142
+ const messageComponents = useMemo(
143
+ () => ({
144
+ AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
145
+ SystemMessage,
146
+ UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
147
+ UserMessage: () => <UserMessage onCancel={onCancel} />
148
+ }),
149
+ [cwd, gateway, onBranchInNewChat, onCancel, sessionId]
150
+ )
151
+
152
+ const emptyPlaceholder = intro ? (
153
+ <div
154
+ className="flex min-h-0 w-full flex-col items-center justify-center"
155
+ style={{ paddingBottom: 'var(--composer-measured-height)' }}
156
+ >
157
+ <Intro {...intro} />
158
+ </div>
159
+ ) : undefined
160
+
161
+ return (
162
+ <GeneratedImageProvider>
163
+ <div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
164
+ <VirtualizedThread
165
+ clampToComposer={clampToComposer}
166
+ components={messageComponents}
167
+ emptyPlaceholder={emptyPlaceholder}
168
+ loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
169
+ sessionKey={sessionKey}
170
+ />
171
+ {loading === 'session' && <CenteredThreadSpinner />}
172
+ </div>
173
+ </GeneratedImageProvider>
174
+ )
175
+ }
176
+
177
+ function pickPrimaryPreviewTarget(targets: string[]): string[] {
178
+ if (targets.length <= 1) {
179
+ return targets
180
+ }
181
+
182
+ const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value))
183
+
184
+ return [localUrl || targets[targets.length - 1]]
185
+ }
186
+
187
+ const CenteredThreadSpinner: FC = () => {
188
+ const { t } = useI18n()
189
+
190
+ return (
191
+ <div
192
+ aria-label={t.assistant.thread.loadingSession}
193
+ className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
194
+ role="status"
195
+ >
196
+ <Loader
197
+ aria-hidden="true"
198
+ className="size-12 text-midground/70"
199
+ pathSteps={220}
200
+ role="presentation"
201
+ strokeScale={0.72}
202
+ type="rose-curve"
203
+ />
204
+ </div>
205
+ )
206
+ }
207
+
208
+ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
209
+ const messageId = useAuiState(s => s.message.id)
210
+ const content = useAuiState(s => s.message.content)
211
+ const messageText = messageContentText(content)
212
+ const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
213
+
214
+ const previewTargets = useMemo(() => {
215
+ if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
216
+ return []
217
+ }
218
+
219
+ return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
220
+ }, [messageText])
221
+
222
+ const messageStatus = useAuiState(s => s.message.status?.type)
223
+ const isPlaceholder = messageStatus === 'running' && content.length === 0
224
+ const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
225
+
226
+ if (isPlaceholder) {
227
+ return null
228
+ }
229
+
230
+ return (
231
+ <MessagePrimitive.Root
232
+ className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
233
+ data-role="assistant"
234
+ data-slot="aui_assistant-message-root"
235
+ data-streaming={messageStatus === 'running' ? 'true' : undefined}
236
+ ref={enterRef}
237
+ >
238
+ <div
239
+ className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
240
+ data-slot="aui_assistant-message-content"
241
+ >
242
+ {hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
243
+ <MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
244
+ {messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
245
+ {previewTargets.length > 0 && (
246
+ <div className="mt-3 flex flex-wrap gap-2">
247
+ {previewTargets.map(target => (
248
+ <PreviewAttachment key={target} source="explicit-link" target={target} />
249
+ ))}
250
+ </div>
251
+ )}
252
+ <MessagePrimitive.Error>
253
+ <ErrorPrimitive.Root
254
+ className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
255
+ role="alert"
256
+ >
257
+ <ErrorPrimitive.Message />
258
+ </ErrorPrimitive.Root>
259
+ </MessagePrimitive.Error>
260
+ </div>
261
+ {messageText.trim().length > 0 && (
262
+ <AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
263
+ )}
264
+ </MessagePrimitive.Root>
265
+ )
266
+ }
267
+
268
+ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
269
+ children,
270
+ label,
271
+ className,
272
+ ...rest
273
+ }) => (
274
+ <div
275
+ aria-label={label}
276
+ aria-live="polite"
277
+ className={cn('flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70', className)}
278
+ role="status"
279
+ {...rest}
280
+ >
281
+ {children}
282
+ </div>
283
+ )
284
+
285
+ const ResponseLoadingIndicator: FC = () => {
286
+ const { t } = useI18n()
287
+ const elapsed = useElapsedSeconds()
288
+
289
+ return (
290
+ <StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
291
+ <span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
292
+ <ActivityTimerText seconds={elapsed} />
293
+ </StatusRow>
294
+ )
295
+ }
296
+
297
+ // Seconds of no visible output (text or part count) before a still-running turn
298
+ // is treated as stalled and the thinking indicator returns at the tail.
299
+ const STREAM_STALL_S = 2
300
+
301
+ // Tail "still thinking" indicator: the pre-first-token spinner goes away once
302
+ // text flows, but if the stream then goes quiet mid-turn (tool think-time,
303
+ // provider stall) nothing signals that work continues. Watch a per-render
304
+ // activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
305
+ // dither + a timer counting from the last activity.
306
+ const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
307
+ const [stalled, setStalled] = useState(false)
308
+
309
+ useEffect(() => {
310
+ setStalled(false)
311
+ const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000)
312
+
313
+ return () => window.clearTimeout(id)
314
+ }, [activity])
315
+
316
+ const elapsed = useElapsedSeconds(stalled)
317
+
318
+ if (!stalled) {
319
+ return null
320
+ }
321
+
322
+ return (
323
+ <StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="NasTech is thinking">
324
+ <span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
325
+ <ActivityTimerText seconds={elapsed} />
326
+ </StatusRow>
327
+ )
328
+ }
329
+
330
+ const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
331
+ const generatedImage = useGeneratedImageContext()
332
+ const running = result === undefined
333
+
334
+ useEffect(() => {
335
+ generatedImage?.setPending(running)
336
+ }, [generatedImage, running])
337
+
338
+ if (!running) {
339
+ return null
340
+ }
341
+
342
+ return (
343
+ <div className="mt-1.5">
344
+ <ImageGenerationPlaceholder />
345
+ </div>
346
+ )
347
+ }
348
+
349
+ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
350
+ // todo parts are hoisted to a dedicated panel above the message content.
351
+ if (props.toolName === 'todo') {
352
+ return null
353
+ }
354
+
355
+ if (props.toolName === 'image_generate') {
356
+ return <ImageGenerateTool {...props} />
357
+ }
358
+
359
+ if (props.toolName === 'clarify') {
360
+ return <ClarifyTool {...props} />
361
+ }
362
+
363
+ return <ToolFallback {...props} />
364
+ }
365
+
366
+ const ThinkingDisclosure: FC<{
367
+ children: ReactNode
368
+ messageRunning?: boolean
369
+ pending?: boolean
370
+ timerKey?: string
371
+ }> = ({ children, messageRunning = false, pending = false, timerKey }) => {
372
+ const { t } = useI18n()
373
+ // `null` = no explicit user toggle yet, defer to the streaming default.
374
+ // The default is "auto-open while streaming, auto-collapse when done" so
375
+ // reasoning surfaces a live preview without manual interaction. The first
376
+ // explicit toggle wins from then on.
377
+ const [userOpen, setUserOpen] = useState<boolean | null>(null)
378
+ const elapsed = useElapsedSeconds(pending, timerKey)
379
+ const scrollRef = useRef<HTMLDivElement | null>(null)
380
+ const contentRef = useRef<HTMLDivElement | null>(null)
381
+ const enterRef = useEnterAnimation(messageRunning, timerKey)
382
+
383
+ const open = userOpen ?? pending
384
+ const isPreview = pending && userOpen === null
385
+
386
+ // While the preview is live, pin the scroll container to the bottom on
387
+ // every content growth so the latest tokens are always visible. Combined
388
+ // with the top mask in styles.css, this reads as text settling in from
389
+ // below while older lines fade out at the top.
390
+ useEffect(() => {
391
+ if (!isPreview) {
392
+ return
393
+ }
394
+
395
+ const el = scrollRef.current
396
+ const content = contentRef.current
397
+
398
+ if (!el || !content) {
399
+ return
400
+ }
401
+
402
+ const pin = () => {
403
+ el.scrollTop = el.scrollHeight
404
+ }
405
+
406
+ pin()
407
+ const observer = new ResizeObserver(pin)
408
+ observer.observe(content)
409
+
410
+ return () => observer.disconnect()
411
+ // Re-run when the disclosure toggles so the observer attaches to the new
412
+ // DOM after expand/collapse (refs are conditionally rendered on `open`).
413
+ }, [isPreview, open])
414
+
415
+ return (
416
+ <div
417
+ className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)"
418
+ data-slot="aui_thinking-disclosure"
419
+ ref={enterRef}
420
+ >
421
+ <DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
422
+ <span className="flex min-w-0 items-baseline gap-1.5">
423
+ <span
424
+ className={cn(
425
+ 'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)',
426
+ pending && 'shimmer text-foreground/55'
427
+ )}
428
+ >
429
+ {t.assistant.thread.thinking}
430
+ </span>
431
+ {pending && (
432
+ <ActivityTimerText
433
+ className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)"
434
+ seconds={elapsed}
435
+ />
436
+ )}
437
+ </span>
438
+ </DisclosureRow>
439
+ {open && (
440
+ <div
441
+ className={cn(
442
+ // Body sits flush with the "Thinking" header — no left indent —
443
+ // and inherits the disclosure-level opacity fade defined in
444
+ // styles.css (~0.67 at rest, 1 on hover/focus).
445
+ 'mt-0.5 w-full min-w-0 max-w-full overflow-hidden wrap-anywhere pb-1',
446
+ isPreview && 'thinking-preview max-h-40'
447
+ )}
448
+ ref={scrollRef}
449
+ >
450
+ <div ref={contentRef}>{children}</div>
451
+ </div>
452
+ )}
453
+ </div>
454
+ )
455
+ }
456
+
457
+ // Self-gate "Thinking…" on this message's own reasoning parts. Reading
458
+ // `thread.isRunning` directly would flicker shimmer/timer on every old
459
+ // assistant whenever the external-store runtime clears+reimports its
460
+ // repository (one ref-identity bump per streaming delta).
461
+ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({
462
+ children,
463
+ endIndex,
464
+ startIndex
465
+ }) => {
466
+ const messageId = useAuiState(s => s.message.id)
467
+ const messageRunning = useAuiState(s => s.message.status?.type === 'running')
468
+
469
+ const pending = useAuiState(
470
+ s =>
471
+ s.thread.isRunning &&
472
+ s.message.status?.type === 'running' &&
473
+ s.message.parts
474
+ .slice(Math.max(0, startIndex))
475
+ .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
476
+ )
477
+
478
+ // A reasoning group with no actual text is pure noise — drop the whole
479
+ // "Thinking" disclosure rather than leave an empty header eating a row. This
480
+ // applies live too: encrypted/spinner-coerced reasoning (Opus reasoning max)
481
+ // never carries visible text, and the bottom-of-thread loader already signals
482
+ // "thinking", so an empty header is never wanted. Real reasoning surfaces the
483
+ // instant its first token lands.
484
+ const hasContent = useAuiState(s =>
485
+ s.message.parts
486
+ .slice(Math.max(0, startIndex), endIndex + 1)
487
+ .some(p => p?.type === 'reasoning' && typeof p.text === 'string' && p.text.trim().length > 0)
488
+ )
489
+
490
+ if (!hasContent) {
491
+ return null
492
+ }
493
+
494
+ return (
495
+ <ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
496
+ {children}
497
+ </ThinkingDisclosure>
498
+ )
499
+ }
500
+
501
+ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
502
+ const displayText = text.trimStart()
503
+ const messageRunning = useAuiState(s => s.message.status?.type === 'running')
504
+ const isRunning = status?.type === 'running' || messageRunning
505
+
506
+ return (
507
+ <MarkdownTextContent
508
+ containerClassName={cn(
509
+ 'text-xs leading-snug text-muted-foreground/85',
510
+ isRunning && 'shimmer text-muted-foreground/55'
511
+ )}
512
+ containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
513
+ isRunning={isRunning}
514
+ text={displayText}
515
+ />
516
+ )
517
+ }
518
+
519
+ // Module-level constant so the `components` prop on `MessagePrimitive.Parts`
520
+ // has a stable identity across renders. Without this every AssistantMessage
521
+ // render would create a fresh `components` object, invalidating the memo on
522
+ // `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to
523
+ // re-render on every streaming delta. Memo invalidation alone doesn't
524
+ // remount, but combined with the previous ToolFallback group-swap it was a
525
+ // big chunk of the per-delta work.
526
+ const MESSAGE_PARTS_COMPONENTS = {
527
+ Reasoning: ReasoningTextPart,
528
+ ReasoningGroup: ReasoningAccordionGroup,
529
+ Text: MarkdownText,
530
+ ToolGroup: ToolGroupSlot,
531
+ tools: { Fallback: ChainToolFallback }
532
+ } as const
533
+
534
+ const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
535
+
536
+ const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
537
+ day: 'numeric',
538
+ hour: 'numeric',
539
+ minute: '2-digit',
540
+ month: 'short'
541
+ })
542
+
543
+ function startOfDay(d: Date): number {
544
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
545
+ }
546
+
547
+ function formatMessageTimestamp(
548
+ value: Date | string | number | undefined,
549
+ labels: { today: (time: string) => string; yesterday: (time: string) => string }
550
+ ): string {
551
+ if (!value) {
552
+ return ''
553
+ }
554
+
555
+ const date = value instanceof Date ? value : new Date(value)
556
+
557
+ if (Number.isNaN(date.getTime())) {
558
+ return ''
559
+ }
560
+
561
+ const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
562
+
563
+ if (dayDelta === 0) {
564
+ return labels.today(TIME_FMT.format(date))
565
+ }
566
+
567
+ if (dayDelta === 1) {
568
+ return labels.yesterday(TIME_FMT.format(date))
569
+ }
570
+
571
+ return SHORT_FMT.format(date)
572
+ }
573
+
574
+ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
575
+ const { t } = useI18n()
576
+ const copy = t.assistant.thread
577
+ const [menuOpen, setMenuOpen] = useState(false)
578
+
579
+ return (
580
+ <div className="relative flex w-full shrink-0 justify-end">
581
+ <ActionBarPrimitive.Root
582
+ className={cn(
583
+ // NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
584
+ // bar while the thread streams, which collapses every completed
585
+ // assistant message's footer by this bar's height and shifts the
586
+ // whole conversation when the turn resolves. The bar is already
587
+ // invisible by default (opacity-0 + pointer-events-none, reveals on
588
+ // hover), so keeping it mounted reserves stable layout height with
589
+ // no visual change during streaming.
590
+ 'relative flex flex-row items-center justify-end gap-2 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
591
+ menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100'
592
+ )}
593
+ data-slot="aui_msg-actions"
594
+ >
595
+ <CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
596
+ <ActionBarPrimitive.Reload asChild>
597
+ <TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
598
+ <Codicon name="refresh" />
599
+ </TooltipIconButton>
600
+ </ActionBarPrimitive.Reload>
601
+ <DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
602
+ <DropdownMenuTrigger asChild>
603
+ <TooltipIconButton tooltip={copy.moreActions}>
604
+ <Codicon name="ellipsis" />
605
+ </TooltipIconButton>
606
+ </DropdownMenuTrigger>
607
+ <DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
608
+ <MessageTimestamp />
609
+ <DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
610
+ <GitBranchIcon />
611
+ {copy.branchNewChat}
612
+ </DropdownMenuItem>
613
+ <ReadAloudItem messageId={messageId} text={messageText} />
614
+ </DropdownMenuContent>
615
+ </DropdownMenu>
616
+ </ActionBarPrimitive.Root>
617
+ </div>
618
+ )
619
+ }
620
+
621
+ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
622
+ const { t } = useI18n()
623
+ const copy = t.assistant.thread
624
+ const voicePlayback = useStore($voicePlayback)
625
+
626
+ const readAloudStatus =
627
+ voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle'
628
+
629
+ const isPreparing = readAloudStatus === 'preparing'
630
+ const isSpeaking = readAloudStatus === 'speaking'
631
+ const anyPlaybackActive = voicePlayback.status !== 'idle'
632
+ const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
633
+
634
+ const read = useCallback(async () => {
635
+ if (!text || $voicePlayback.get().status !== 'idle') {
636
+ return
637
+ }
638
+
639
+ try {
640
+ await playSpeechText(text, { messageId, source: 'read-aloud' })
641
+ } catch (error) {
642
+ notifyError(error, copy.readAloudFailed)
643
+ }
644
+ }, [copy.readAloudFailed, messageId, text])
645
+
646
+ return (
647
+ <DropdownMenuItem
648
+ disabled={isPreparing || (!isSpeaking && (anyPlaybackActive || !text))}
649
+ onSelect={e => {
650
+ e.preventDefault()
651
+ void (isSpeaking ? stopVoicePlayback() : read())
652
+ }}
653
+ >
654
+ <Icon className={isPreparing ? 'animate-spin' : undefined} />
655
+ {isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
656
+ </DropdownMenuItem>
657
+ )
658
+ }
659
+
660
+ const MessageTimestamp: FC = () => {
661
+ const { t } = useI18n()
662
+ const createdAt = useAuiState(s => s.message.createdAt)
663
+ const label = formatMessageTimestamp(createdAt, t.assistant.thread)
664
+
665
+ if (!label) {
666
+ return null
667
+ }
668
+
669
+ return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
670
+ }
671
+
672
+ const AssistantFooter: FC<MessageActionProps> = props => (
673
+ <div className="flex min-h-6 flex-col items-end gap-1 pr-(--message-text-indent) pl-(--message-text-indent)">
674
+ <BranchPickerPrimitive.Root
675
+ className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
676
+ hideWhenSingleBranch
677
+ >
678
+ <BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
679
+ <Codicon name="chevron-left" size="0.875rem" />
680
+ </BranchPickerPrimitive.Previous>
681
+ <span className="tabular-nums">
682
+ <BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
683
+ </span>
684
+ <BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
685
+ <Codicon name="chevron-right" size="0.875rem" />
686
+ </BranchPickerPrimitive.Next>
687
+ </BranchPickerPrimitive.Root>
688
+ <AssistantActionBar {...props} />
689
+ </div>
690
+ )
691
+
692
+ const EMPTY_ATTACHMENT_REFS: string[] = []
693
+
694
+ function messageAttachmentRefs(value: unknown): string[] {
695
+ if (!Array.isArray(value)) {
696
+ return EMPTY_ATTACHMENT_REFS
697
+ }
698
+
699
+ return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
700
+ }
701
+
702
+ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
703
+ return (
704
+ <div
705
+ className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
706
+ data-role="user"
707
+ data-slot="aui_user-message-root"
708
+ >
709
+ {children}
710
+ </div>
711
+ )
712
+ }
713
+
714
+ // Shared "user bubble" base. Both the read-only message and the inline
715
+ // edit composer render the same bubble surface (rounded glass card);
716
+ // they only differ in border weight, cursor, and padding-right (the
717
+ // read-only view reserves room for the restore icon).
718
+ const USER_BUBBLE_BASE_CLASS =
719
+ 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
720
+
721
+ const USER_ACTION_ICON_BUTTON_CLASS =
722
+ 'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
723
+
724
+ const USER_ACTION_ICON_SIZE = '0.6875rem'
725
+ const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
726
+
727
+ const UserMessage: FC<{
728
+ onCancel?: () => Promise<void> | void
729
+ }> = ({ onCancel }) => {
730
+ const { t } = useI18n()
731
+ const copy = t.assistant.thread
732
+ const messageId = useAuiState(s => s.message.id)
733
+ const content = useAuiState(s => s.message.content)
734
+ const messageText = messageContentText(content)
735
+ const threadRunning = useAuiState(s => s.thread.isRunning)
736
+
737
+ const latestUserId = useAuiState(s => {
738
+ for (let i = s.thread.messages.length - 1; i >= 0; i--) {
739
+ const message = s.thread.messages[i] as { id?: string; role?: string }
740
+
741
+ if (message.role === 'user') {
742
+ return message.id ?? null
743
+ }
744
+ }
745
+
746
+ return null
747
+ })
748
+
749
+ const attachmentRefs = useAuiState(s => {
750
+ const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
751
+
752
+ return messageAttachmentRefs(custom.attachmentRefs)
753
+ })
754
+
755
+ // Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
756
+ // doesn't dominate the viewport while the response streams underneath; the
757
+ // clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
758
+ // inner wrapper so the ResizeObserver only fires on real content / width
759
+ // changes, not on every frame while the outer max-height animates open.
760
+ const clampInnerRef = useRef<HTMLDivElement | null>(null)
761
+ const [bodyClamped, setBodyClamped] = useState(false)
762
+
763
+ const measureClamp = useCallback(() => {
764
+ const inner = clampInnerRef.current
765
+ const outer = inner?.parentElement
766
+
767
+ if (!inner || !outer) {
768
+ return
769
+ }
770
+
771
+ const styles = getComputedStyle(inner)
772
+ const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
773
+ const fullHeight = inner.scrollHeight
774
+
775
+ outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
776
+ setBodyClamped(fullHeight > lineHeight * 2 + 1)
777
+ }, [])
778
+
779
+ useResizeObserver(measureClamp, clampInnerRef)
780
+
781
+ const hasBody = messageText.trim().length > 0
782
+ const isLatestUser = messageId === latestUserId
783
+ const showStop = isLatestUser && threadRunning && Boolean(onCancel)
784
+ const showRestore = !isLatestUser && !threadRunning
785
+
786
+ const bubbleClassName = cn(
787
+ USER_BUBBLE_BASE_CLASS,
788
+ 'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
789
+ !threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)'
790
+ )
791
+
792
+ const bubbleContent = (
793
+ <>
794
+ {attachmentRefs.length > 0 && (
795
+ <span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
796
+ <DirectiveContent text={attachmentRefs.join(' ')} />
797
+ </span>
798
+ )}
799
+ {hasBody && (
800
+ // Render the user's text through a minimal markdown pipeline:
801
+ // backtick `code` and ``` fenced ``` blocks, with directive chips
802
+ // (`@file:` etc.) still resolved inside the plain-text spans.
803
+ <div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
804
+ <div ref={clampInnerRef}>
805
+ <UserMessageText className="wrap-anywhere" text={messageText} />
806
+ </div>
807
+ </div>
808
+ )}
809
+ </>
810
+ )
811
+
812
+ return (
813
+ <MessagePrimitive.Root asChild>
814
+ <StickyHumanMessageContainer>
815
+ <ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
816
+ <div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
817
+ <div className="relative w-full">
818
+ {threadRunning ? (
819
+ <div className={bubbleClassName}>{bubbleContent}</div>
820
+ ) : (
821
+ <ActionBarPrimitive.Edit asChild>
822
+ <button
823
+ aria-label={copy.editMessage}
824
+ className={bubbleClassName}
825
+ onClick={() => triggerHaptic('selection')}
826
+ title={copy.editMessage}
827
+ type="button"
828
+ >
829
+ {bubbleContent}
830
+ </button>
831
+ </ActionBarPrimitive.Edit>
832
+ )}
833
+ {(showStop || showRestore) && (
834
+ <div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
835
+ {showStop ? (
836
+ <button
837
+ aria-label={copy.stop}
838
+ className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
839
+ onClick={event => {
840
+ event.preventDefault()
841
+ event.stopPropagation()
842
+ void onCancel?.()
843
+ }}
844
+ title={copy.stop}
845
+ type="button"
846
+ >
847
+ {StopGlyph}
848
+ </button>
849
+ ) : (
850
+ <span
851
+ aria-hidden="true"
852
+ className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
853
+ title={copy.editableCheckpoint}
854
+ >
855
+ <Codicon name="discard" size="0.875rem" />
856
+ </span>
857
+ )}
858
+ </div>
859
+ )}
860
+ </div>
861
+ <BranchPickerPrimitive.Root
862
+ className="checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)"
863
+ hideWhenSingleBranch
864
+ >
865
+ <span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
866
+ <BranchPickerPrimitive.Previous
867
+ className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
868
+ title={copy.restorePrevious}
869
+ >
870
+ {copy.restoreCheckpoint}
871
+ </BranchPickerPrimitive.Previous>
872
+ <span className="checkpoint-divider opacity-55">
873
+ <BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
874
+ </span>
875
+ <BranchPickerPrimitive.Next
876
+ className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
877
+ title={copy.restoreNext}
878
+ >
879
+ {copy.goForward}
880
+ </BranchPickerPrimitive.Next>
881
+ </BranchPickerPrimitive.Root>
882
+ </div>
883
+ </ActionBarPrimitive.Root>
884
+ </StickyHumanMessageContainer>
885
+ </MessagePrimitive.Root>
886
+ )
887
+ }
888
+
889
+ const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
890
+ const STEER_NOTE_RE = /^steer:(?<text>[\s\S]+)$/
891
+
892
+ const SystemMessage: FC = () => {
893
+ const text = useAuiState(s => messageContentText(s.message.content))
894
+
895
+ if (!text) {
896
+ return null
897
+ }
898
+
899
+ const steerNote = text.match(STEER_NOTE_RE)
900
+
901
+ if (steerNote?.groups) {
902
+ return (
903
+ <MessagePrimitive.Root
904
+ className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60"
905
+ data-role="system"
906
+ data-slot="aui_system-message-root"
907
+ >
908
+ <Codicon className="text-muted-foreground/55" name="compass" size="0.75rem" />
909
+ <span className="text-muted-foreground/55">steered</span>
910
+ <span className="text-muted-foreground/35">·</span>
911
+ <span className="whitespace-pre-wrap">{steerNote.groups.text.trim()}</span>
912
+ </MessagePrimitive.Root>
913
+ )
914
+ }
915
+
916
+ const slashStatus = text.match(SLASH_STATUS_RE)
917
+
918
+ if (slashStatus?.groups) {
919
+ return (
920
+ <MessagePrimitive.Root
921
+ className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
922
+ data-role="system"
923
+ data-slot="aui_system-message-root"
924
+ >
925
+ <span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
926
+ <span className="mx-1.5 text-muted-foreground/35">·</span>
927
+ <span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
928
+ </MessagePrimitive.Root>
929
+ )
930
+ }
931
+
932
+ return (
933
+ <MessagePrimitive.Root
934
+ className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
935
+ data-role="system"
936
+ data-slot="aui_system-message-root"
937
+ >
938
+ <span className="whitespace-pre-wrap">{text}</span>
939
+ </MessagePrimitive.Root>
940
+ )
941
+ }
942
+
943
+ interface UserEditComposerProps {
944
+ cwd: string | null
945
+ gateway: NasTechGateway | null
946
+ sessionId: string | null
947
+ }
948
+
949
+ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
950
+ const { t } = useI18n()
951
+ const copy = t.assistant.thread
952
+ const aui = useAui()
953
+ const draft = useAuiState(s => s.composer.text)
954
+ const rootRef = useRef<HTMLDivElement | null>(null)
955
+ const editorRef = useRef<HTMLDivElement | null>(null)
956
+ const draftRef = useRef(draft)
957
+ const dragDepthRef = useRef(0)
958
+ const [dragActive, setDragActive] = useState(false)
959
+ const [trigger, setTrigger] = useState<TriggerState | null>(null)
960
+ const [triggerActive, setTriggerActive] = useState(0)
961
+ const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
962
+ // See index.tsx: set in keydown when the open popover consumes a nav/control
963
+ // key so the matching keyup skips refreshTrigger (timing-immune vs reading
964
+ // `trigger`, which keyup sees as already-null after Escape).
965
+ const triggerKeyConsumedRef = useRef(false)
966
+ const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
967
+ const [focusRequestId, setFocusRequestId] = useState(0)
968
+ const [submitting, setSubmitting] = useState(false)
969
+ const expanded = draft.includes('\n')
970
+ const canSubmit = draft.trim().length > 0
971
+ const at = useAtCompletions({ cwd, gateway, sessionId })
972
+ const slash = useSlashCompletions({ gateway })
973
+
974
+ const focusEditor = useCallback(() => {
975
+ const editor = editorRef.current
976
+
977
+ focusComposerInput(editor)
978
+
979
+ if (editor) {
980
+ placeCaretEnd(editor)
981
+ }
982
+
983
+ markActiveComposer('edit')
984
+ }, [])
985
+
986
+ const requestEditFocus = useCallback(() => {
987
+ setFocusRequestId(id => id + 1)
988
+ }, [])
989
+
990
+ const appendExternalText = useCallback(
991
+ (text: string, mode: ComposerInsertMode) => {
992
+ const value = text.trim()
993
+
994
+ if (!value) {
995
+ return
996
+ }
997
+
998
+ const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
999
+ const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
1000
+ const next = `${base}${sep}${value}`
1001
+
1002
+ draftRef.current = next
1003
+ aui.composer().setText(next)
1004
+
1005
+ const editor = editorRef.current
1006
+
1007
+ if (editor) {
1008
+ renderComposerContents(editor, next)
1009
+ placeCaretEnd(editor)
1010
+ }
1011
+
1012
+ setFocusRequestId(id => id + 1)
1013
+ },
1014
+ [aui]
1015
+ )
1016
+
1017
+ useEffect(() => {
1018
+ draftRef.current = draft
1019
+
1020
+ const editor = editorRef.current
1021
+
1022
+ if (
1023
+ editor &&
1024
+ (editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
1025
+ ) {
1026
+ renderComposerContents(editor, draft)
1027
+
1028
+ if (document.activeElement === editor) {
1029
+ placeCaretEnd(editor)
1030
+ }
1031
+ }
1032
+ }, [draft])
1033
+
1034
+ useEffect(() => {
1035
+ focusEditor()
1036
+ }, [focusEditor, focusRequestId])
1037
+
1038
+ useEffect(() => {
1039
+ const offFocus = onComposerFocusRequest(target => {
1040
+ if (target === 'edit') {
1041
+ setFocusRequestId(id => id + 1)
1042
+ }
1043
+ })
1044
+
1045
+ const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
1046
+ if (target === 'edit') {
1047
+ appendExternalText(text, mode)
1048
+ }
1049
+ })
1050
+
1051
+ return () => {
1052
+ offFocus()
1053
+ offInsert()
1054
+ }
1055
+ }, [appendExternalText])
1056
+
1057
+ const syncDraftFromEditor = useCallback(
1058
+ (editor: HTMLDivElement) => {
1059
+ const nextDraft = composerPlainText(editor)
1060
+
1061
+ if (nextDraft !== draftRef.current) {
1062
+ draftRef.current = nextDraft
1063
+ aui.composer().setText(nextDraft)
1064
+ }
1065
+
1066
+ return nextDraft
1067
+ },
1068
+ [aui]
1069
+ )
1070
+
1071
+ const refreshTrigger = useCallback(() => {
1072
+ const editor = editorRef.current
1073
+
1074
+ if (!editor) {
1075
+ return
1076
+ }
1077
+
1078
+ const before = textBeforeCaret(editor)
1079
+ const detected = detectTrigger(before ?? composerPlainText(editor))
1080
+
1081
+ if (detected) {
1082
+ const rect = editor.getBoundingClientRect()
1083
+ const spaceAbove = rect.top
1084
+ const spaceBelow = window.innerHeight - rect.bottom
1085
+
1086
+ setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top')
1087
+ }
1088
+
1089
+ setTrigger(detected)
1090
+
1091
+ // Only reset the highlight when the trigger actually changed (opened, or
1092
+ // the query/kind differs). Re-detecting the *same* trigger — e.g. on a
1093
+ // caret move (mouseup) or a stray refresh — must preserve the user's
1094
+ // current selection instead of snapping back to the first item.
1095
+ if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
1096
+ setTriggerActive(0)
1097
+ }
1098
+ }, [trigger])
1099
+
1100
+ const closeTrigger = useCallback(() => {
1101
+ setTrigger(null)
1102
+ setTriggerItems([])
1103
+ setTriggerActive(0)
1104
+ }, [])
1105
+
1106
+ const triggerAdapter: Unstable_TriggerAdapter | null =
1107
+ trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
1108
+
1109
+ useEffect(() => {
1110
+ if (!trigger || !triggerAdapter?.search) {
1111
+ setTriggerItems([])
1112
+
1113
+ return
1114
+ }
1115
+
1116
+ setTriggerItems(triggerAdapter.search(trigger.query))
1117
+ }, [trigger, triggerAdapter])
1118
+
1119
+ useEffect(() => {
1120
+ setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
1121
+ }, [triggerItems.length])
1122
+
1123
+ const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
1124
+
1125
+ const replaceTriggerWithChip = useCallback(
1126
+ (item: Unstable_TriggerItem) => {
1127
+ const editor = editorRef.current
1128
+
1129
+ if (!editor || !trigger) {
1130
+ return
1131
+ }
1132
+
1133
+ const serialized = NASTECHDirectiveFormatter.serialize(item)
1134
+ const starter = serialized.endsWith(':')
1135
+ const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
1136
+ const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
1137
+
1138
+ const finish = () => {
1139
+ draftRef.current = composerPlainText(editor)
1140
+ aui.composer().setText(draftRef.current)
1141
+ requestEditFocus()
1142
+ starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
1143
+ }
1144
+
1145
+ const sel = window.getSelection()
1146
+ const range = sel?.rangeCount ? sel.getRangeAt(0) : null
1147
+ const node = range?.startContainer
1148
+ const offset = range?.startOffset ?? 0
1149
+
1150
+ if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
1151
+ const current = composerPlainText(editor)
1152
+ renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
1153
+ placeCaretEnd(editor)
1154
+
1155
+ return finish()
1156
+ }
1157
+
1158
+ const replaceRange = document.createRange()
1159
+ replaceRange.setStart(node, offset - trigger.tokenLength)
1160
+ replaceRange.setEnd(node, offset)
1161
+ replaceRange.deleteContents()
1162
+
1163
+ if (directive) {
1164
+ const chip = refChipElement(directive[1], directive[2])
1165
+ const space = document.createTextNode(' ')
1166
+ const fragment = document.createDocumentFragment()
1167
+ fragment.append(chip, space)
1168
+ replaceRange.insertNode(fragment)
1169
+
1170
+ const caret = document.createRange()
1171
+ caret.setStart(space, 1)
1172
+ caret.collapse(true)
1173
+ sel.removeAllRanges()
1174
+ sel.addRange(caret)
1175
+
1176
+ return finish()
1177
+ }
1178
+
1179
+ document.execCommand('insertText', false, text)
1180
+ finish()
1181
+ },
1182
+ [aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
1183
+ )
1184
+
1185
+ const insertDroppedRefs = useCallback(
1186
+ (candidates: ReturnType<typeof extractDroppedFiles>) => {
1187
+ const editor = editorRef.current
1188
+
1189
+ if (!editor) {
1190
+ return false
1191
+ }
1192
+
1193
+ const refs = candidates
1194
+ .map(candidate => droppedFileInlineRef(candidate, cwd))
1195
+ .filter((ref): ref is string => Boolean(ref))
1196
+
1197
+ const nextDraft = insertInlineRefsIntoEditor(editor, refs)
1198
+
1199
+ if (nextDraft === null) {
1200
+ return false
1201
+ }
1202
+
1203
+ draftRef.current = nextDraft
1204
+ aui.composer().setText(nextDraft)
1205
+ requestEditFocus()
1206
+
1207
+ return true
1208
+ },
1209
+ [aui, cwd, requestEditFocus]
1210
+ )
1211
+
1212
+ const resetDragState = useCallback(() => {
1213
+ dragDepthRef.current = 0
1214
+ setDragActive(false)
1215
+ }, [])
1216
+
1217
+ const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
1218
+ if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
1219
+ return
1220
+ }
1221
+
1222
+ event.preventDefault()
1223
+ dragDepthRef.current += 1
1224
+
1225
+ if (!dragActive) {
1226
+ setDragActive(true)
1227
+ }
1228
+ }
1229
+
1230
+ const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
1231
+ if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
1232
+ return
1233
+ }
1234
+
1235
+ event.preventDefault()
1236
+ event.dataTransfer.dropEffect = 'copy'
1237
+ }
1238
+
1239
+ const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
1240
+ event.preventDefault()
1241
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
1242
+
1243
+ if (dragDepthRef.current === 0) {
1244
+ setDragActive(false)
1245
+ }
1246
+ }
1247
+
1248
+ const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
1249
+ if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
1250
+ return
1251
+ }
1252
+
1253
+ const candidates = extractDroppedFiles(event.dataTransfer)
1254
+
1255
+ if (!candidates.length) {
1256
+ return
1257
+ }
1258
+
1259
+ event.preventDefault()
1260
+ event.stopPropagation()
1261
+ resetDragState()
1262
+
1263
+ if (insertDroppedRefs(candidates)) {
1264
+ triggerHaptic('selection')
1265
+ }
1266
+ }
1267
+
1268
+ const handleInput = (event: FormEvent<HTMLDivElement>) => {
1269
+ const editor = event.currentTarget
1270
+
1271
+ if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
1272
+ editor.replaceChildren()
1273
+ }
1274
+
1275
+ syncDraftFromEditor(editor)
1276
+ window.setTimeout(refreshTrigger, 0)
1277
+ }
1278
+
1279
+ const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
1280
+ const pastedText = event.clipboardData.getData('text')
1281
+
1282
+ if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
1283
+ event.preventDefault()
1284
+
1285
+ return
1286
+ }
1287
+
1288
+ event.preventDefault()
1289
+ document.execCommand('insertText', false, pastedText)
1290
+ syncDraftFromEditor(event.currentTarget)
1291
+ }
1292
+
1293
+ const submitEdit = (editor: HTMLDivElement) => {
1294
+ const nextDraft = syncDraftFromEditor(editor)
1295
+
1296
+ if (submitting || !nextDraft.trim()) {
1297
+ return
1298
+ }
1299
+
1300
+ setSubmitting(true)
1301
+ aui.composer().send()
1302
+ }
1303
+
1304
+ const handleEditBlur = useCallback(
1305
+ (event: FocusEvent<HTMLDivElement>) => {
1306
+ const nextTarget = event.relatedTarget
1307
+
1308
+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
1309
+ return
1310
+ }
1311
+
1312
+ window.setTimeout(() => {
1313
+ const root = rootRef.current
1314
+ const active = document.activeElement
1315
+
1316
+ if (submitting || (root && active && root.contains(active))) {
1317
+ return
1318
+ }
1319
+
1320
+ closeTrigger()
1321
+ aui.composer().cancel()
1322
+ }, 80)
1323
+ },
1324
+ [aui, closeTrigger, submitting]
1325
+ )
1326
+
1327
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
1328
+ if (trigger && triggerItems.length > 0) {
1329
+ if (event.key === 'ArrowDown') {
1330
+ event.preventDefault()
1331
+ triggerKeyConsumedRef.current = true
1332
+ setTriggerActive(idx => (idx + 1) % triggerItems.length)
1333
+
1334
+ return
1335
+ }
1336
+
1337
+ if (event.key === 'ArrowUp') {
1338
+ event.preventDefault()
1339
+ triggerKeyConsumedRef.current = true
1340
+ setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
1341
+
1342
+ return
1343
+ }
1344
+
1345
+ if (event.key === 'Enter' || event.key === 'Tab') {
1346
+ event.preventDefault()
1347
+ triggerKeyConsumedRef.current = true
1348
+ const item = triggerItems[triggerActive]
1349
+
1350
+ if (item) {
1351
+ replaceTriggerWithChip(item)
1352
+ }
1353
+
1354
+ return
1355
+ }
1356
+
1357
+ if (event.key === 'Escape') {
1358
+ event.preventDefault()
1359
+ triggerKeyConsumedRef.current = true
1360
+ closeTrigger()
1361
+
1362
+ return
1363
+ }
1364
+ }
1365
+
1366
+ if (event.key === 'Escape') {
1367
+ event.preventDefault()
1368
+ aui.composer().cancel()
1369
+
1370
+ return
1371
+ }
1372
+
1373
+ if (event.key === 'Enter' && !event.shiftKey) {
1374
+ event.preventDefault()
1375
+ submitEdit(event.currentTarget)
1376
+ }
1377
+ }
1378
+
1379
+ const handleKeyUp = () => {
1380
+ // If this keyup belongs to a key the open trigger popover already consumed
1381
+ // in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
1382
+ // edit text, and for Escape the keydown already closed the menu — a refresh
1383
+ // here would re-detect the still-present `/` and instantly reopen it. We
1384
+ // read a ref set during keydown rather than `trigger`, because by keyup
1385
+ // time React has re-rendered and `trigger` may already be null.
1386
+ if (triggerKeyConsumedRef.current) {
1387
+ triggerKeyConsumedRef.current = false
1388
+
1389
+ return
1390
+ }
1391
+
1392
+ window.setTimeout(refreshTrigger, 0)
1393
+ }
1394
+
1395
+ return (
1396
+ <ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
1397
+ <StickyHumanMessageContainer>
1398
+ <div
1399
+ className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--ui-chat-surface-background)"
1400
+ onBlur={handleEditBlur}
1401
+ onDragEnter={handleDragEnter}
1402
+ onDragLeave={handleDragLeave}
1403
+ onDragOver={handleDragOver}
1404
+ onDrop={handleDrop}
1405
+ ref={rootRef}
1406
+ >
1407
+ {trigger && (
1408
+ <ComposerTriggerPopover
1409
+ activeIndex={triggerActive}
1410
+ items={triggerItems}
1411
+ kind={trigger.kind}
1412
+ loading={triggerLoading}
1413
+ onHover={setTriggerActive}
1414
+ onPick={replaceTriggerWithChip}
1415
+ placement={triggerPlacement}
1416
+ />
1417
+ )}
1418
+ <div
1419
+ className={cn(
1420
+ USER_BUBBLE_BASE_CLASS,
1421
+ 'ui-prompt-input__container relative border-(--ui-stroke-secondary) data-[expanded=true]:min-h-20',
1422
+ COMPOSER_DROP_FADE_CLASS,
1423
+ dragActive && COMPOSER_DROP_ACTIVE_CLASS
1424
+ )}
1425
+ data-expanded={expanded ? 'true' : undefined}
1426
+ >
1427
+ <div
1428
+ aria-label={copy.editMessage}
1429
+ autoFocus
1430
+ className={cn(
1431
+ 'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
1432
+ 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
1433
+ '**:data-ref-text:cursor-default',
1434
+ expanded ? 'min-h-16' : 'min-h-[1.25rem]'
1435
+ )}
1436
+ contentEditable
1437
+ data-placeholder={copy.editMessage}
1438
+ data-slot={RICH_INPUT_SLOT}
1439
+ onBlur={() => window.setTimeout(closeTrigger, 80)}
1440
+ onDragOver={handleDragOver}
1441
+ onDrop={handleDrop}
1442
+ onFocus={() => markActiveComposer('edit')}
1443
+ onInput={handleInput}
1444
+ onKeyDown={handleKeyDown}
1445
+ onKeyUp={handleKeyUp}
1446
+ onMouseUp={refreshTrigger}
1447
+ onPaste={handlePaste}
1448
+ ref={editorRef}
1449
+ role="textbox"
1450
+ suppressContentEditableWarning
1451
+ />
1452
+ <ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
1453
+ <button
1454
+ aria-label={copy.sendEdited}
1455
+ className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
1456
+ disabled={!canSubmit || submitting}
1457
+ onClick={() => {
1458
+ const editor = editorRef.current
1459
+
1460
+ if (editor) {
1461
+ submitEdit(editor)
1462
+ }
1463
+ }}
1464
+ title={copy.sendEdited}
1465
+ type="button"
1466
+ >
1467
+ {submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
1468
+ </button>
1469
+ </div>
1470
+ </div>
1471
+ </StickyHumanMessageContainer>
1472
+ </ComposerPrimitive.Root>
1473
+ )
1474
+ }