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,1611 @@
1
+ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
2
+ import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
3
+ import { useStore } from '@nanostores/react'
4
+ import {
5
+ type ClipboardEvent,
6
+ type FormEvent,
7
+ type KeyboardEvent,
8
+ type DragEvent as ReactDragEvent,
9
+ useCallback,
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState
14
+ } from 'react'
15
+
16
+ import { NASTECHDirectiveFormatter } from '@/components/assistant-ui/directive-text'
17
+ import { Button } from '@/components/ui/button'
18
+ import { useMediaQuery } from '@/hooks/use-media-query'
19
+ import { useResizeObserver } from '@/hooks/use-resize-observer'
20
+ import { useI18n } from '@/i18n'
21
+ import { chatMessageText } from '@/lib/chat-messages'
22
+ import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
23
+ import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
24
+ import { triggerHaptic } from '@/lib/haptics'
25
+ import { cn } from '@/lib/utils'
26
+ import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
27
+ import {
28
+ browseBackward,
29
+ browseForward,
30
+ deriveUserHistory,
31
+ isBrowsingHistory,
32
+ resetBrowseState
33
+ } from '@/store/composer-input-history'
34
+ import {
35
+ $queuedPromptsBySession,
36
+ enqueueQueuedPrompt,
37
+ promoteQueuedPrompt,
38
+ type QueuedPromptEntry,
39
+ removeQueuedPrompt,
40
+ shouldAutoDrainOnSettle,
41
+ updateQueuedPrompt
42
+ } from '@/store/composer-queue'
43
+ import { $gatewayState, $messages } from '@/store/session'
44
+ import { $threadScrolledUp } from '@/store/thread-scroll'
45
+
46
+ import { extractDroppedFiles, NASTECH_PATHS_MIME } from '../hooks/use-composer-actions'
47
+
48
+ import { AttachmentList } from './attachments'
49
+ import { ContextMenu } from './context-menu'
50
+ import { ComposerControls } from './controls'
51
+ import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
52
+ import {
53
+ type ComposerInsertMode,
54
+ focusComposerInput,
55
+ markActiveComposer,
56
+ onComposerFocusRequest,
57
+ onComposerInsertRefsRequest,
58
+ onComposerInsertRequest
59
+ } from './focus'
60
+ import { HelpHint } from './help-hint'
61
+ import { useAtCompletions } from './hooks/use-at-completions'
62
+ import { useSlashCompletions } from './hooks/use-slash-completions'
63
+ import { useVoiceConversation } from './hooks/use-voice-conversation'
64
+ import { useVoiceRecorder } from './hooks/use-voice-recorder'
65
+ import {
66
+ dragHasAttachments,
67
+ droppedFileInlineRef,
68
+ type InlineRefInput,
69
+ insertInlineRefsIntoEditor
70
+ } from './inline-refs'
71
+ import { QueuePanel } from './queue-panel'
72
+ import {
73
+ composerPlainText,
74
+ placeCaretEnd,
75
+ refChipElement,
76
+ renderComposerContents,
77
+ RICH_INPUT_SLOT
78
+ } from './rich-editor'
79
+ import { SkinSlashPopover } from './skin-slash-popover'
80
+ import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
81
+ import { ComposerTriggerPopover } from './trigger-popover'
82
+ import type { ChatBarProps } from './types'
83
+ import { UrlDialog } from './url-dialog'
84
+ import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
85
+
86
+ const COMPOSER_STACK_BREAKPOINT_PX = 320
87
+
88
+ // A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
89
+ // vertical padding). Anything taller means the text wrapped to a second line,
90
+ // which is when the composer should expand to the stacked layout.
91
+ const COMPOSER_SINGLE_LINE_MAX_PX = 36
92
+
93
+ const COMPOSER_FADE_BACKGROUND =
94
+ 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
95
+
96
+ const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
97
+
98
+ interface QueueEditState {
99
+ attachments: ComposerAttachment[]
100
+ draft: string
101
+ entryId: string
102
+ sessionKey: string
103
+ }
104
+
105
+ const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
106
+
107
+ export function ChatBar({
108
+ busy,
109
+ cwd,
110
+ disabled,
111
+ focusKey,
112
+ gateway,
113
+ maxRecordingSeconds = 120,
114
+ queueSessionKey,
115
+ sessionId,
116
+ state,
117
+ onCancel,
118
+ onAddUrl,
119
+ onAttachDroppedItems,
120
+ onAttachImageBlob,
121
+ onPasteClipboardImage,
122
+ onPickFiles,
123
+ onPickFolders,
124
+ onPickImages,
125
+ onRemoveAttachment,
126
+ onSteer,
127
+ onSubmit,
128
+ onTranscribeAudio
129
+ }: ChatBarProps) {
130
+ const aui = useAui()
131
+ const draft = useAuiState(s => s.composer.text)
132
+ const attachments = useStore($composerAttachments)
133
+ const queuedPromptsBySession = useStore($queuedPromptsBySession)
134
+ const scrolledUp = useStore($threadScrolledUp)
135
+ const sessionMessages = useStore($messages)
136
+ const activeQueueSessionKey = queueSessionKey || sessionId || null
137
+
138
+ const queuedPrompts = useMemo(
139
+ () => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []),
140
+ [activeQueueSessionKey, queuedPromptsBySession]
141
+ )
142
+
143
+ const composerRef = useRef<HTMLFormElement | null>(null)
144
+ const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
145
+ const editorRef = useRef<HTMLDivElement | null>(null)
146
+ const draftRef = useRef(draft)
147
+ const previousBusyRef = useRef(busy)
148
+ const drainingQueueRef = useRef(false)
149
+ const urlInputRef = useRef<HTMLInputElement | null>(null)
150
+
151
+ const [urlOpen, setUrlOpen] = useState(false)
152
+ const [urlValue, setUrlValue] = useState('')
153
+ const [expanded, setExpanded] = useState(false)
154
+ const [voiceConversationActive, setVoiceConversationActive] = useState(false)
155
+ const [tight, setTight] = useState(false)
156
+ const [dragActive, setDragActive] = useState(false)
157
+ const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
158
+ const [focusRequestId, setFocusRequestId] = useState(0)
159
+ const dragDepthRef = useRef(0)
160
+ const composingRef = useRef(false) // true during IME composition (CJK input)
161
+ const lastSpokenIdRef = useRef<string | null>(null)
162
+
163
+ const narrow = useMediaQuery('(max-width: 30rem)')
164
+
165
+ const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
166
+ const slash = useSlashCompletions({ gateway: gateway ?? null })
167
+
168
+ const stacked = expanded || narrow || tight
169
+ const trimmedDraft = draft.trim()
170
+ const hasComposerPayload = trimmedDraft.length > 0 || attachments.length > 0
171
+ const canSubmit = busy || hasComposerPayload
172
+ const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
173
+ const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
174
+ // Steer only makes sense mid-turn, text-only (the gateway can't carry images
175
+ // into a tool result) and never for a slash command (those execute inline).
176
+ const canSteer =
177
+ busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
178
+ const showHelpHint = draft === '?'
179
+
180
+ const { t } = useI18n()
181
+ const gatewayState = useStore($gatewayState)
182
+ const newSessionPlaceholders = t.composer.newSessionPlaceholders
183
+ const followUpPlaceholders = t.composer.followUpPlaceholders
184
+
185
+ // Resting placeholder: a starter for brand-new sessions, a continuation for
186
+ // existing ones. Picked once and only re-rolled when we genuinely move to a
187
+ // *different* conversation. Critically, the first id assignment of a freshly
188
+ // started session (null → id, on the first send) is treated as the same
189
+ // conversation so the placeholder doesn't visibly flip mid-stream.
190
+ const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
191
+ pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
192
+ )
193
+
194
+ const prevSessionIdRef = useRef(sessionId)
195
+
196
+ useEffect(() => {
197
+ const prev = prevSessionIdRef.current
198
+ prevSessionIdRef.current = sessionId
199
+
200
+ if (prev === sessionId) {
201
+ return
202
+ }
203
+
204
+ // null → id: the new session we're already in just got persisted. Keep the
205
+ // starter we showed instead of swapping to a follow-up under the user.
206
+ if (prev == null && sessionId) {
207
+ return
208
+ }
209
+
210
+ resetBrowseState(prev)
211
+ setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
212
+ }, [followUpPlaceholders, newSessionPlaceholders, sessionId])
213
+
214
+ // When the bar is disabled it's because the gateway isn't open. Distinguish a
215
+ // cold start ("Starting NasTech...") from a dropped connection we're trying to
216
+ // restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
217
+ const placeholder = disabled
218
+ ? gatewayState === 'closed' || gatewayState === 'error'
219
+ ? t.composer.placeholderReconnecting
220
+ : t.composer.placeholderStarting
221
+ : restingPlaceholder
222
+
223
+ const focusInput = useCallback(() => {
224
+ focusComposerInput(editorRef.current)
225
+ markActiveComposer('main')
226
+ }, [])
227
+
228
+ const requestMainFocus = useCallback(() => {
229
+ setFocusRequestId(id => id + 1)
230
+ }, [])
231
+
232
+ const appendExternalText = useCallback(
233
+ (text: string, mode: ComposerInsertMode) => {
234
+ const value = text.trim()
235
+
236
+ if (!value) {
237
+ return
238
+ }
239
+
240
+ const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
241
+ const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
242
+ const next = `${base}${sep}${value}`
243
+
244
+ draftRef.current = next
245
+ aui.composer().setText(next)
246
+
247
+ const editor = editorRef.current
248
+
249
+ if (editor) {
250
+ renderComposerContents(editor, next)
251
+ placeCaretEnd(editor)
252
+ }
253
+
254
+ setFocusRequestId(id => id + 1)
255
+ },
256
+ [aui]
257
+ )
258
+
259
+ useEffect(() => {
260
+ if (!disabled) {
261
+ focusInput()
262
+ }
263
+ }, [disabled, focusInput, focusKey, focusRequestId])
264
+
265
+ useEffect(() => {
266
+ if (disabled) {
267
+ return undefined
268
+ }
269
+
270
+ const offFocus = onComposerFocusRequest(target => {
271
+ if (target === 'main') {
272
+ setFocusRequestId(id => id + 1)
273
+ }
274
+ })
275
+
276
+ const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
277
+ if (target === 'main') {
278
+ appendExternalText(text, mode)
279
+ }
280
+ })
281
+
282
+ return () => {
283
+ offFocus()
284
+ offInsert()
285
+ }
286
+ }, [appendExternalText, disabled])
287
+
288
+ // Keep draftRef in sync with the assistant-ui composer state for callers
289
+ // that read the latest text outside the React render cycle. We don't push
290
+ // to `$composerDraft` per keystroke any more — nobody outside the composer
291
+ // subscribes to it (verified by grep), and the round-trip
292
+ // `setText` ⇄ `subscribe` ⇄ `setText` was adding two useEffects to the per-
293
+ // keystroke critical path. `reconcileComposerTerminalSelections` only
294
+ // matters when the draft is submitted; we now call it from the submit
295
+ // path instead.
296
+ useEffect(() => {
297
+ draftRef.current = draft
298
+
299
+ const editor = editorRef.current
300
+
301
+ if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) {
302
+ renderComposerContents(editor, draft)
303
+ }
304
+ }, [draft])
305
+
306
+ useEffect(() => {
307
+ if (urlOpen) {
308
+ window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true }))
309
+ }
310
+ }, [urlOpen])
311
+
312
+ // Expansion (input on its own full-width row, controls below) is driven by
313
+ // the editor's *actual* rendered height via the ResizeObserver in
314
+ // syncComposerMetrics — it only fires when the text genuinely wraps to a
315
+ // second line, so the layout flips exactly at the wrap point rather than at
316
+ // a guessed character count. We only handle the two cases the observer
317
+ // can't: an explicit newline (expand before layout settles) and an emptied
318
+ // draft (collapse back). We never read scrollHeight per keystroke.
319
+ useEffect(() => {
320
+ if (!draft) {
321
+ setExpanded(false)
322
+
323
+ return
324
+ }
325
+
326
+ if (expanded) {
327
+ return
328
+ }
329
+
330
+ if (draft.includes('\n')) {
331
+ setExpanded(true)
332
+ }
333
+ }, [draft, expanded])
334
+
335
+ // Bucket measured heights so we only invalidate the global CSS var when
336
+ // the size crosses a meaningful threshold. Without bucketing, the editor
337
+ // grows ~1px per character → setProperty fires every keystroke → entire
338
+ // tree's computed style is invalidated → next paint forces a full
339
+ // recalculate-style pass. With an 8px bucket, the invalidation rate drops
340
+ // ~8× and small char-by-char typing produces no style invalidation at all
341
+ // until a wrap or row change actually happens.
342
+ const lastBucketedHeightRef = useRef(0)
343
+ const lastBucketedSurfaceHeightRef = useRef(0)
344
+ const lastTightRef = useRef<boolean | null>(null)
345
+
346
+ const syncComposerMetrics = useCallback(() => {
347
+ const composer = composerRef.current
348
+
349
+ if (!composer) {
350
+ return
351
+ }
352
+
353
+ const { height, width } = composer.getBoundingClientRect()
354
+ const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height
355
+ const root = document.documentElement
356
+
357
+ if (width > 0) {
358
+ const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX
359
+
360
+ if (nextTight !== lastTightRef.current) {
361
+ lastTightRef.current = nextTight
362
+ setTight(nextTight)
363
+ }
364
+ }
365
+
366
+ // Expand once the input has actually wrapped past a single line. The
367
+ // observer only fires on real size changes, so this reads scrollHeight at
368
+ // most once per wrap (not per keystroke). One line ≈ 28px (1.625rem
369
+ // min-height + padding); a second line clears ~36px. We only ever expand
370
+ // here — collapse is handled by the emptied-draft effect to avoid
371
+ // oscillating across the wrap boundary as the input switches widths.
372
+ const editor = editorRef.current
373
+
374
+ if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) {
375
+ setExpanded(true)
376
+ }
377
+
378
+ if (height > 0) {
379
+ const bucket = Math.round(height / 8) * 8
380
+
381
+ if (bucket !== lastBucketedHeightRef.current) {
382
+ lastBucketedHeightRef.current = bucket
383
+ root.style.setProperty('--composer-measured-height', `${bucket}px`)
384
+ }
385
+ }
386
+
387
+ if (surfaceHeight && surfaceHeight > 0) {
388
+ const bucket = Math.round(surfaceHeight / 8) * 8
389
+
390
+ if (bucket !== lastBucketedSurfaceHeightRef.current) {
391
+ lastBucketedSurfaceHeightRef.current = bucket
392
+ root.style.setProperty('--composer-surface-measured-height', `${bucket}px`)
393
+ }
394
+ }
395
+ }, [])
396
+
397
+ useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
398
+
399
+ useEffect(() => {
400
+ return () => {
401
+ const root = document.documentElement
402
+ root.style.removeProperty('--composer-measured-height')
403
+ root.style.removeProperty('--composer-surface-measured-height')
404
+ }
405
+ }, [])
406
+
407
+ const insertText = (text: string) => {
408
+ const currentDraft = draftRef.current
409
+ const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : ''
410
+ const nextDraft = `${currentDraft}${sep}${text}`
411
+
412
+ draftRef.current = nextDraft
413
+ aui.composer().setText(nextDraft)
414
+
415
+ // Push the new text into the contentEditable editor directly. Setting the
416
+ // assistant-ui composer state alone is not enough: the draft→editor sync
417
+ // effect only re-renders the editor when it is NOT focused
418
+ // (document.activeElement !== editor), and the dictation/insert paths
419
+ // typically run while the editor has (or immediately regains) focus — so
420
+ // the store would hold the text but the visible editor would stay empty
421
+ // and there'd be nothing to send. Mirror appendExternalText here.
422
+ const editor = editorRef.current
423
+
424
+ if (editor) {
425
+ renderComposerContents(editor, nextDraft)
426
+ placeCaretEnd(editor)
427
+ }
428
+
429
+ requestMainFocus()
430
+ }
431
+
432
+ const insertInlineRefs = (refs: InlineRefInput[]) => {
433
+ const editor = editorRef.current
434
+
435
+ if (!editor) {
436
+ return false
437
+ }
438
+
439
+ const nextDraft = insertInlineRefsIntoEditor(editor, refs)
440
+
441
+ if (nextDraft === null) {
442
+ return false
443
+ }
444
+
445
+ draftRef.current = nextDraft
446
+ aui.composer().setText(nextDraft)
447
+ requestMainFocus()
448
+
449
+ return true
450
+ }
451
+
452
+ // Latest-closure ref so the (once-only) subscription always calls the current
453
+ // insertInlineRefs without re-subscribing every render.
454
+ const insertInlineRefsRef = useRef(insertInlineRefs)
455
+ insertInlineRefsRef.current = insertInlineRefs
456
+
457
+ useEffect(() => {
458
+ return onComposerInsertRefsRequest(({ refs, target }) => {
459
+ if (target === 'main') {
460
+ insertInlineRefsRef.current(refs)
461
+ }
462
+ })
463
+ }, [])
464
+
465
+ const selectSkinSlashCommand = (command: string) => {
466
+ draftRef.current = command
467
+ aui.composer().setText(command)
468
+ requestMainFocus()
469
+ }
470
+
471
+ const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
472
+ const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
473
+
474
+ if (imageBlobs.length > 0) {
475
+ event.preventDefault()
476
+
477
+ if (onAttachImageBlob) {
478
+ triggerHaptic('selection')
479
+
480
+ for (const blob of imageBlobs) {
481
+ void onAttachImageBlob(blob)
482
+ }
483
+ }
484
+
485
+ return
486
+ }
487
+
488
+ // Trim surrounding whitespace so a copy that dragged along leading/trailing
489
+ // blank lines (common when selecting from terminals, code blocks, web pages)
490
+ // doesn't dump multiline padding into the composer. Internal newlines are
491
+ // preserved — only the edges are cleaned up.
492
+ const pastedText = event.clipboardData.getData('text').trim()
493
+
494
+ if (!pastedText) {
495
+ event.preventDefault()
496
+
497
+ return
498
+ }
499
+
500
+ if (DATA_IMAGE_URL_RE.test(pastedText)) {
501
+ event.preventDefault()
502
+
503
+ return
504
+ }
505
+
506
+ event.preventDefault()
507
+ document.execCommand('insertText', false, pastedText)
508
+ const nextDraft = composerPlainText(event.currentTarget)
509
+ draftRef.current = nextDraft
510
+ aui.composer().setText(nextDraft)
511
+ }
512
+
513
+ const [trigger, setTrigger] = useState<TriggerState | null>(null)
514
+ const [triggerActive, setTriggerActive] = useState(0)
515
+ const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
516
+ // Set synchronously in keydown when the open trigger popover consumes a
517
+ // navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
518
+ // NOT run refreshTrigger for that keypress: it never edits text, and for
519
+ // Escape the keydown has already set trigger=null, so a keyup refresh would
520
+ // re-detect the still-present `/` and instantly reopen the menu. A ref is
521
+ // used instead of reading `trigger` in keyup because by keyup time React has
522
+ // re-rendered and the handler closure sees the post-keydown state.
523
+ const triggerKeyConsumedRef = useRef(false)
524
+
525
+ const refreshTrigger = useCallback(() => {
526
+ const editor = editorRef.current
527
+
528
+ if (!editor) {
529
+ return
530
+ }
531
+
532
+ // Fast-bail: if neither `@` nor `/` appears in the current draft, there's
533
+ // nothing for `detectTrigger` to match. Use `textContent` (cheap browser-
534
+ // native walk) for the precondition check rather than `composerPlainText`
535
+ // (recursive child walk with chip-aware logic). Only when a trigger char
536
+ // is present do we pay the cost of the full walk + DOM range work.
537
+ const rawText = editor.textContent ?? ''
538
+
539
+ if (!rawText.includes('@') && !rawText.includes('/')) {
540
+ if (trigger) {
541
+ setTrigger(null)
542
+ setTriggerActive(0)
543
+ }
544
+
545
+ return
546
+ }
547
+
548
+ const before = textBeforeCaret(editor)
549
+ const detected = detectTrigger(before ?? composerPlainText(editor))
550
+
551
+ setTrigger(detected)
552
+
553
+ // Only reset the highlight when the trigger actually changed (opened, or
554
+ // the query/kind differs). Re-detecting the *same* trigger — e.g. on a
555
+ // caret move (mouseup) or a stray refresh — must preserve the user's
556
+ // current selection instead of snapping back to the first item.
557
+ if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
558
+ setTriggerActive(0)
559
+ }
560
+ }, [trigger])
561
+
562
+ // Pull the live contentEditable text into draftRef + the AUI composer state
563
+ // (which drives `hasComposerPayload` → the send button). Shared by the input
564
+ // and compositionend paths so committed IME text reaches state through either.
565
+ const flushEditorToDraft = (editor: HTMLDivElement) => {
566
+ if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
567
+ editor.replaceChildren()
568
+ }
569
+
570
+ const nextDraft = composerPlainText(editor)
571
+
572
+ if (nextDraft !== draftRef.current) {
573
+ draftRef.current = nextDraft
574
+ aui.composer().setText(nextDraft)
575
+ }
576
+
577
+ window.setTimeout(refreshTrigger, 0)
578
+ }
579
+
580
+ const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
581
+ // During IME composition the DOM contains uncommitted preedit text
582
+ // mixed with real content. Skip state writes — compositionend flushes
583
+ // the finalized text (see onCompositionEnd).
584
+ if (composingRef.current) {
585
+ return
586
+ }
587
+
588
+ flushEditorToDraft(event.currentTarget)
589
+ }
590
+
591
+ const triggerAdapter: Unstable_TriggerAdapter | null =
592
+ trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
593
+
594
+ useEffect(() => {
595
+ if (!trigger || !triggerAdapter?.search) {
596
+ setTriggerItems([])
597
+
598
+ return
599
+ }
600
+
601
+ setTriggerItems(triggerAdapter.search(trigger.query))
602
+ }, [trigger, triggerAdapter])
603
+
604
+ const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
605
+
606
+ const closeTrigger = () => {
607
+ setTrigger(null)
608
+ setTriggerItems([])
609
+ setTriggerActive(0)
610
+ }
611
+
612
+ useEffect(() => {
613
+ setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
614
+ }, [triggerItems.length])
615
+
616
+ const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
617
+ const editor = editorRef.current
618
+
619
+ if (!editor || !trigger) {
620
+ return
621
+ }
622
+
623
+ const serialized = NASTECHDirectiveFormatter.serialize(item)
624
+ const starter = serialized.endsWith(':')
625
+ const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
626
+ const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
627
+
628
+ const finish = () => {
629
+ draftRef.current = composerPlainText(editor)
630
+ aui.composer().setText(draftRef.current)
631
+ requestMainFocus()
632
+ starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
633
+ }
634
+
635
+ const sel = window.getSelection()
636
+ const range = sel?.rangeCount ? sel.getRangeAt(0) : null
637
+ const node = range?.startContainer
638
+ const offset = range?.startOffset ?? 0
639
+
640
+ if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
641
+ const current = composerPlainText(editor)
642
+ renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
643
+ placeCaretEnd(editor)
644
+
645
+ return finish()
646
+ }
647
+
648
+ const replaceRange = document.createRange()
649
+ replaceRange.setStart(node, offset - trigger.tokenLength)
650
+ replaceRange.setEnd(node, offset)
651
+ replaceRange.deleteContents()
652
+
653
+ if (directive) {
654
+ const chip = refChipElement(directive[1], directive[2])
655
+ const space = document.createTextNode(' ')
656
+ const fragment = document.createDocumentFragment()
657
+ fragment.append(chip, space)
658
+ replaceRange.insertNode(fragment)
659
+
660
+ const caret = document.createRange()
661
+ caret.setStart(space, 1)
662
+ caret.collapse(true)
663
+ sel.removeAllRanges()
664
+ sel.addRange(caret)
665
+
666
+ return finish()
667
+ }
668
+
669
+ document.execCommand('insertText', false, text)
670
+ finish()
671
+ }
672
+
673
+ const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
674
+ // IME composition: Enter confirms composed text, not a message submission.
675
+ // We check both composingRef (set by compositionstart/compositionend, robust
676
+ // across browsers) and nativeEvent.isComposing (Chromium fallback). Without
677
+ // this guard, pressing Enter to finalise a Korean/Japanese/Chinese IME
678
+ // preedit fires submitDraft() and splits the message mid-word.
679
+ if (composingRef.current || event.nativeEvent.isComposing) {
680
+ return
681
+ }
682
+
683
+ // Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
684
+ // reserved for the global command palette.
685
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
686
+ event.preventDefault()
687
+
688
+ if (!busy) {
689
+ void drainNextQueued()
690
+ }
691
+
692
+ return
693
+ }
694
+
695
+ if (trigger && triggerItems.length > 0) {
696
+ if (event.key === 'ArrowDown') {
697
+ event.preventDefault()
698
+ triggerKeyConsumedRef.current = true
699
+ setTriggerActive(idx => (idx + 1) % triggerItems.length)
700
+
701
+ return
702
+ }
703
+
704
+ if (event.key === 'ArrowUp') {
705
+ event.preventDefault()
706
+ triggerKeyConsumedRef.current = true
707
+ setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
708
+
709
+ return
710
+ }
711
+
712
+ if (event.key === 'Enter' || event.key === 'Tab') {
713
+ event.preventDefault()
714
+ triggerKeyConsumedRef.current = true
715
+ const item = triggerItems[triggerActive]
716
+
717
+ if (item) {
718
+ replaceTriggerWithChip(item)
719
+ }
720
+
721
+ return
722
+ }
723
+
724
+ if (event.key === 'Escape') {
725
+ event.preventDefault()
726
+ triggerKeyConsumedRef.current = true
727
+ closeTrigger()
728
+
729
+ return
730
+ }
731
+ }
732
+
733
+ // ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
734
+ // place) then sent-message history. The history ring is derived from live
735
+ // session messages each press — single source of truth, no mirror.
736
+ if (event.key === 'ArrowUp') {
737
+ const currentDraft = draftRef.current
738
+
739
+ // Editing a queued turn → walk to the older entry.
740
+ if (queueEdit && stepQueuedEdit(-1)) {
741
+ event.preventDefault()
742
+ triggerKeyConsumedRef.current = true
743
+
744
+ return
745
+ }
746
+
747
+ // Empty composer + a queued turn → open the newest queued entry for edit
748
+ // (the row's pencil), not a text recall. Enter saves it back to the queue.
749
+ if (!currentDraft.trim() && !queueEdit && queuedPrompts.length > 0) {
750
+ event.preventDefault()
751
+ triggerKeyConsumedRef.current = true
752
+ beginQueuedEdit(queuedPrompts[queuedPrompts.length - 1]!)
753
+
754
+ return
755
+ }
756
+
757
+ // Don't hijack a typed draft unless already browsing — they'd lose it.
758
+ if (currentDraft.trim() && !isBrowsingHistory(sessionId)) {
759
+ return
760
+ }
761
+
762
+ event.preventDefault()
763
+ triggerKeyConsumedRef.current = true
764
+
765
+ const history = deriveUserHistory(sessionMessages, chatMessageText)
766
+ const entry = browseBackward(sessionId, currentDraft, history)
767
+
768
+ if (entry !== null) {
769
+ loadIntoComposer(entry, $composerAttachments.get())
770
+ }
771
+
772
+ return
773
+ }
774
+
775
+ if (event.key === 'ArrowDown') {
776
+ // Editing a queued turn → walk to the newer entry (past the newest exits).
777
+ if (queueEdit) {
778
+ event.preventDefault()
779
+ triggerKeyConsumedRef.current = true
780
+ stepQueuedEdit(1)
781
+
782
+ return
783
+ }
784
+
785
+ // Browsing sent history → step toward the present, restoring the draft.
786
+ if (isBrowsingHistory(sessionId)) {
787
+ event.preventDefault()
788
+ triggerKeyConsumedRef.current = true
789
+
790
+ const history = deriveUserHistory(sessionMessages, chatMessageText)
791
+ const result = browseForward(sessionId, history)
792
+
793
+ if (result !== null) {
794
+ loadIntoComposer(result.text, $composerAttachments.get())
795
+ }
796
+ }
797
+
798
+ return
799
+ }
800
+
801
+ // Cmd/Ctrl+Enter is reserved for steering the live run — never a send.
802
+ // Steer when there's a steerable draft, otherwise swallow it so it can't
803
+ // surprise-send. (Plain Enter still queues while busy / sends when idle.)
804
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey) {
805
+ event.preventDefault()
806
+
807
+ if (canSteer) {
808
+ steerDraft()
809
+ }
810
+
811
+ return
812
+ }
813
+
814
+ if (event.key === 'Enter' && !event.shiftKey) {
815
+ event.preventDefault()
816
+
817
+ if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
818
+ void drainNextQueued()
819
+
820
+ return
821
+ }
822
+
823
+ // Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
824
+ // never a stray Enter after sending. With a payload, submitDraft queues it.
825
+ if (busy && !hasComposerPayload) {
826
+ return
827
+ }
828
+
829
+ submitDraft()
830
+
831
+ return
832
+ }
833
+
834
+ if (event.key === 'Escape') {
835
+ // Editing a queued turn → Esc cancels the edit, restoring the prior draft.
836
+ if (queueEdit) {
837
+ event.preventDefault()
838
+ exitQueuedEdit('cancel')
839
+
840
+ return
841
+ }
842
+
843
+ // Otherwise Esc interrupts the running turn (Stop-button parity).
844
+ if (busy) {
845
+ event.preventDefault()
846
+ triggerHaptic('cancel')
847
+ void Promise.resolve(onCancel())
848
+ }
849
+ }
850
+ }
851
+
852
+ const handleEditorKeyUp = () => {
853
+ // If this keyup belongs to a key the open trigger popover already consumed
854
+ // in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
855
+ // edit text, and for Escape the keydown already closed the menu — a refresh
856
+ // here would re-detect the still-present `/` and instantly reopen it. We
857
+ // read a ref set during keydown rather than `trigger`, because by keyup
858
+ // time React has re-rendered and `trigger` may already be null.
859
+ if (triggerKeyConsumedRef.current) {
860
+ triggerKeyConsumedRef.current = false
861
+
862
+ return
863
+ }
864
+
865
+ window.setTimeout(refreshTrigger, 0)
866
+ }
867
+
868
+ const resetDragState = () => {
869
+ dragDepthRef.current = 0
870
+ setDragActive(false)
871
+ }
872
+
873
+ const handleDragEnter = (event: ReactDragEvent<HTMLFormElement>) => {
874
+ if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
875
+ return
876
+ }
877
+
878
+ event.preventDefault()
879
+ dragDepthRef.current += 1
880
+
881
+ if (!dragActive) {
882
+ setDragActive(true)
883
+ }
884
+ }
885
+
886
+ const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
887
+ if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
888
+ return
889
+ }
890
+
891
+ event.preventDefault()
892
+ event.dataTransfer.dropEffect = 'copy'
893
+ }
894
+
895
+ const handleDragLeave = (event: ReactDragEvent<HTMLFormElement>) => {
896
+ if (!onAttachDroppedItems) {
897
+ return
898
+ }
899
+
900
+ event.preventDefault()
901
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
902
+
903
+ if (dragDepthRef.current === 0) {
904
+ setDragActive(false)
905
+ }
906
+ }
907
+
908
+ const handleDrop = (event: ReactDragEvent<HTMLFormElement>) => {
909
+ if (!onAttachDroppedItems) {
910
+ return
911
+ }
912
+
913
+ event.preventDefault()
914
+ resetDragState()
915
+
916
+ const candidates = extractDroppedFiles(event.dataTransfer)
917
+
918
+ if (candidates.length === 0) {
919
+ return
920
+ }
921
+
922
+ if (Array.from(event.dataTransfer.types || []).includes(NASTECH_PATHS_MIME)) {
923
+ const refs = candidates
924
+ .map(candidate => droppedFileInlineRef(candidate, cwd))
925
+ .filter((ref): ref is string => Boolean(ref))
926
+
927
+ if (insertInlineRefs(refs)) {
928
+ triggerHaptic('selection')
929
+ }
930
+
931
+ return
932
+ }
933
+
934
+ void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
935
+ if (attached) {
936
+ triggerHaptic('selection')
937
+ requestMainFocus()
938
+ }
939
+ })
940
+ }
941
+
942
+ const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
943
+ if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
944
+ return
945
+ }
946
+
947
+ event.preventDefault()
948
+ event.stopPropagation()
949
+ event.dataTransfer.dropEffect = 'copy'
950
+ }
951
+
952
+ const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
953
+ if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
954
+ return
955
+ }
956
+
957
+ const candidates = extractDroppedFiles(event.dataTransfer)
958
+
959
+ const refs = candidates
960
+ .map(candidate => droppedFileInlineRef(candidate, cwd))
961
+ .filter((ref): ref is string => Boolean(ref))
962
+
963
+ if (!refs.length) {
964
+ return
965
+ }
966
+
967
+ event.preventDefault()
968
+ event.stopPropagation()
969
+ resetDragState()
970
+
971
+ if (insertInlineRefs(refs)) {
972
+ triggerHaptic('selection')
973
+ }
974
+ }
975
+
976
+ const clearDraft = useCallback(() => {
977
+ aui.composer().setText('')
978
+ draftRef.current = ''
979
+
980
+ if (editorRef.current) {
981
+ editorRef.current.replaceChildren()
982
+ }
983
+ }, [aui])
984
+
985
+ const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
986
+ draftRef.current = text
987
+ aui.composer().setText(text)
988
+ $composerAttachments.set(cloneAttachments(attachments))
989
+
990
+ const editor = editorRef.current
991
+
992
+ if (editor) {
993
+ renderComposerContents(editor, text)
994
+ placeCaretEnd(editor)
995
+ }
996
+ }
997
+
998
+ const beginQueuedEdit = (entry: QueuedPromptEntry) => {
999
+ if (!activeQueueSessionKey || queueEdit) {
1000
+ return
1001
+ }
1002
+
1003
+ setQueueEdit({
1004
+ attachments: cloneAttachments($composerAttachments.get()),
1005
+ draft: draftRef.current,
1006
+ entryId: entry.id,
1007
+ sessionKey: activeQueueSessionKey
1008
+ })
1009
+ loadIntoComposer(entry.text, entry.attachments)
1010
+ triggerHaptic('selection')
1011
+ focusInput()
1012
+ }
1013
+
1014
+ // Walk queued entries while editing (ArrowUp = older, ArrowDown = newer),
1015
+ // saving the in-progress edit on each step. Stepping newer past the last
1016
+ // entry exits edit mode and restores the pre-edit draft.
1017
+ const stepQueuedEdit = (direction: -1 | 1) => {
1018
+ if (!queueEdit) {
1019
+ return false
1020
+ }
1021
+
1022
+ const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId)
1023
+ const target = index + direction
1024
+
1025
+ if (index < 0 || target < 0) {
1026
+ return index >= 0 // at the oldest: swallow; missing entry: let it fall through
1027
+ }
1028
+
1029
+ const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
1030
+ attachments: cloneAttachments($composerAttachments.get()),
1031
+ text: draftRef.current
1032
+ })
1033
+
1034
+ const next = queuedPrompts[target]
1035
+
1036
+ if (next) {
1037
+ setQueueEdit({ ...queueEdit, entryId: next.id })
1038
+ loadIntoComposer(next.text, next.attachments)
1039
+ } else {
1040
+ setQueueEdit(null)
1041
+ loadIntoComposer(queueEdit.draft, queueEdit.attachments)
1042
+ }
1043
+
1044
+ triggerHaptic(saved ? 'success' : 'selection')
1045
+ focusInput()
1046
+
1047
+ return true
1048
+ }
1049
+
1050
+ const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
1051
+ if (!queueEdit) {
1052
+ return false
1053
+ }
1054
+
1055
+ if (action === 'save') {
1056
+ const text = draftRef.current
1057
+ const next = cloneAttachments($composerAttachments.get())
1058
+
1059
+ if (!text.trim() && next.length === 0) {
1060
+ return false
1061
+ }
1062
+
1063
+ const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text })
1064
+ triggerHaptic(saved ? 'success' : 'selection')
1065
+ } else {
1066
+ triggerHaptic('cancel')
1067
+ }
1068
+
1069
+ loadIntoComposer(queueEdit.draft, queueEdit.attachments)
1070
+ setQueueEdit(null)
1071
+ focusInput()
1072
+
1073
+ return true
1074
+ }
1075
+
1076
+ const queueCurrentDraft = useCallback(() => {
1077
+ if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) {
1078
+ return false
1079
+ }
1080
+
1081
+ if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) {
1082
+ return false
1083
+ }
1084
+
1085
+ clearDraft()
1086
+ clearComposerAttachments()
1087
+ triggerHaptic('selection')
1088
+
1089
+ return true
1090
+ }, [activeQueueSessionKey, attachments, clearDraft, draft])
1091
+
1092
+ // Steer the live turn (nudge without interrupting). Clears the draft up front
1093
+ // for snappy feedback; if the gateway rejects (no live tool window) the words
1094
+ // are re-queued so nothing is lost — same safety net as a plain queue.
1095
+ const steerDraft = useCallback(() => {
1096
+ if (!onSteer || !canSteer) {
1097
+ return
1098
+ }
1099
+
1100
+ const text = draftRef.current.trim()
1101
+
1102
+ triggerHaptic('submit')
1103
+ clearDraft()
1104
+
1105
+ void Promise.resolve(onSteer(text)).then(accepted => {
1106
+ if (!accepted && activeQueueSessionKey) {
1107
+ enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] })
1108
+ }
1109
+ })
1110
+ }, [activeQueueSessionKey, canSteer, clearDraft, onSteer])
1111
+
1112
+ // All queue drain paths share one lock + send-then-remove sequence.
1113
+ // `pickEntry` lets each caller choose head, by-id, or skip-edited.
1114
+ const runDrain = useCallback(
1115
+ async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise<boolean> => {
1116
+ if (drainingQueueRef.current || !activeQueueSessionKey) {
1117
+ return false
1118
+ }
1119
+
1120
+ const entry = pickEntry(queuedPrompts)
1121
+
1122
+ if (!entry) {
1123
+ return false
1124
+ }
1125
+
1126
+ drainingQueueRef.current = true
1127
+
1128
+ try {
1129
+ const accepted = await Promise.resolve(
1130
+ onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })
1131
+ )
1132
+
1133
+ if (accepted === false) {
1134
+ return false
1135
+ }
1136
+
1137
+ removeQueuedPrompt(activeQueueSessionKey, entry.id)
1138
+ resetBrowseState(sessionId)
1139
+
1140
+ return true
1141
+ } finally {
1142
+ drainingQueueRef.current = false
1143
+ }
1144
+ },
1145
+ [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
1146
+ )
1147
+
1148
+ const drainNextQueued = useCallback(
1149
+ () =>
1150
+ runDrain(entries => {
1151
+ const skip = queueEdit?.entryId
1152
+
1153
+ return skip ? entries.find(e => e.id !== skip) : entries[0]
1154
+ }),
1155
+ [queueEdit, runDrain]
1156
+ )
1157
+
1158
+ const sendQueuedNow = useCallback(
1159
+ (id: string) => {
1160
+ if (!activeQueueSessionKey || id === queueEdit?.entryId) {
1161
+ return false
1162
+ }
1163
+
1164
+ if (busy) {
1165
+ // Promote to the head, then interrupt. The gateway always emits a
1166
+ // settle (message.complete + session.info running:false) when the
1167
+ // turn unwinds, and the busy→false auto-drain below sends this entry.
1168
+ promoteQueuedPrompt(activeQueueSessionKey, id)
1169
+ triggerHaptic('selection')
1170
+ void Promise.resolve(onCancel())
1171
+
1172
+ return true
1173
+ }
1174
+
1175
+ return runDrain(entries => entries.find(e => e.id === id))
1176
+ },
1177
+ [activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
1178
+ )
1179
+
1180
+ // Auto-drain on busy → false (turn settled). Queued turns always flow once
1181
+ // the session is idle again — whether the turn finished naturally or the
1182
+ // user interrupted it. Interrupting to reach a queued message is the whole
1183
+ // point of the queue, so we never suppress the drain. To cancel queued
1184
+ // turns, the user deletes them from the panel.
1185
+ useEffect(() => {
1186
+ const wasBusy = previousBusyRef.current
1187
+ previousBusyRef.current = busy
1188
+
1189
+ if (
1190
+ shouldAutoDrainOnSettle({
1191
+ isBusy: busy,
1192
+ queueLength: queuedPrompts.length,
1193
+ wasBusy
1194
+ })
1195
+ ) {
1196
+ void drainNextQueued()
1197
+ }
1198
+ }, [busy, drainNextQueued, queuedPrompts.length])
1199
+
1200
+ // Clean up queue edit when its target disappears (session swap or external delete).
1201
+ useEffect(() => {
1202
+ if (!queueEdit) {
1203
+ return
1204
+ }
1205
+
1206
+ if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
1207
+ return
1208
+ }
1209
+
1210
+ loadIntoComposer(queueEdit.draft, queueEdit.attachments)
1211
+ setQueueEdit(null)
1212
+ }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
1213
+
1214
+ const submitDraft = () => {
1215
+ if (queueEdit) {
1216
+ exitQueuedEdit('save')
1217
+ } else if (busy) {
1218
+ // Slash commands should execute immediately even while the agent is
1219
+ // busy — they're client-side operations (/yolo, /skin, /new, /help,
1220
+ // etc.) or self-contained gateway RPCs (/status, /compress). onSubmit
1221
+ // routes them to executeSlashCommand, which has its own per-command
1222
+ // busy guard for commands that genuinely need an idle session (skill
1223
+ // /send directives). Queuing them would make every slash command wait
1224
+ // for the current turn to finish, which is how the TUI never behaves.
1225
+ if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
1226
+ const submitted = draft
1227
+ triggerHaptic('submit')
1228
+ clearDraft()
1229
+ void onSubmit(submitted)
1230
+ } else if (hasComposerPayload) {
1231
+ queueCurrentDraft()
1232
+ } else {
1233
+ // Stop button (the only way to reach here while busy with an empty
1234
+ // composer — empty Enter is short-circuited in the keydown handler).
1235
+ triggerHaptic('cancel')
1236
+ void Promise.resolve(onCancel())
1237
+ }
1238
+ } else if (!hasComposerPayload && queuedPrompts.length > 0) {
1239
+ void drainNextQueued()
1240
+ } else if (draft.trim() || attachments.length > 0) {
1241
+ const submitted = draft
1242
+ triggerHaptic('submit')
1243
+ resetBrowseState(sessionId)
1244
+ clearDraft()
1245
+ clearComposerAttachments()
1246
+ void onSubmit(submitted, { attachments })
1247
+ }
1248
+
1249
+ focusInput()
1250
+ }
1251
+
1252
+ const submitUrl = () => {
1253
+ const url = urlValue.trim()
1254
+
1255
+ if (!url) {
1256
+ return
1257
+ }
1258
+
1259
+ if (onAddUrl) {
1260
+ onAddUrl(url)
1261
+ } else {
1262
+ insertText(`@url:${url}`)
1263
+ }
1264
+
1265
+ triggerHaptic('success')
1266
+ setUrlValue('')
1267
+ setUrlOpen(false)
1268
+ }
1269
+
1270
+ const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
1271
+ focusInput,
1272
+ maxRecordingSeconds,
1273
+ onTranscript: insertText,
1274
+ onTranscribeAudio
1275
+ })
1276
+
1277
+ const pendingResponse = () => {
1278
+ const messages = $messages.get()
1279
+ const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
1280
+
1281
+ if (!last || last.id === lastSpokenIdRef.current) {
1282
+ return null
1283
+ }
1284
+
1285
+ const text = chatMessageText(last).trim()
1286
+
1287
+ if (!text) {
1288
+ return null
1289
+ }
1290
+
1291
+ return {
1292
+ id: last.id,
1293
+ pending: Boolean(last.pending),
1294
+ text
1295
+ }
1296
+ }
1297
+
1298
+ const consumePendingResponse = () => {
1299
+ const messages = $messages.get()
1300
+ const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
1301
+
1302
+ if (last) {
1303
+ lastSpokenIdRef.current = last.id
1304
+ }
1305
+ }
1306
+
1307
+ const submitVoiceTurn = async (text: string) => {
1308
+ if (busy) {
1309
+ return
1310
+ }
1311
+
1312
+ triggerHaptic('submit')
1313
+ resetBrowseState(sessionId)
1314
+ clearDraft()
1315
+ await onSubmit(text)
1316
+ }
1317
+
1318
+ const conversation = useVoiceConversation({
1319
+ busy,
1320
+ consumePendingResponse,
1321
+ enabled: voiceConversationActive,
1322
+ onFatalError: () => setVoiceConversationActive(false),
1323
+ onSubmit: submitVoiceTurn,
1324
+ onTranscribeAudio,
1325
+ pendingResponse
1326
+ })
1327
+
1328
+ const contextMenu = (
1329
+ <ContextMenu
1330
+ onInsertText={insertText}
1331
+ onOpenUrlDialog={() => {
1332
+ triggerHaptic('open')
1333
+ setUrlOpen(true)
1334
+ }}
1335
+ onPasteClipboardImage={onPasteClipboardImage}
1336
+ onPickFiles={onPickFiles}
1337
+ onPickFolders={onPickFolders}
1338
+ onPickImages={onPickImages}
1339
+ state={state}
1340
+ />
1341
+ )
1342
+
1343
+ const controls = (
1344
+ <ComposerControls
1345
+ busy={busy}
1346
+ busyAction={busyAction}
1347
+ canSteer={canSteer}
1348
+ canSubmit={canSubmit}
1349
+ conversation={{
1350
+ active: voiceConversationActive,
1351
+ level: conversation.level,
1352
+ muted: conversation.muted,
1353
+ onEnd: () => {
1354
+ setVoiceConversationActive(false)
1355
+ void conversation.end()
1356
+ },
1357
+ onStart: () => setVoiceConversationActive(true),
1358
+ onStopTurn: conversation.stopTurn,
1359
+ onToggleMute: conversation.toggleMute,
1360
+ status: conversation.status
1361
+ }}
1362
+ disabled={disabled}
1363
+ hasComposerPayload={hasComposerPayload}
1364
+ onDictate={dictate}
1365
+ onSteer={steerDraft}
1366
+ state={state}
1367
+ voiceStatus={voiceStatus}
1368
+ />
1369
+ )
1370
+
1371
+ const input = (
1372
+ <div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
1373
+ <div
1374
+ aria-label={t.composer.message}
1375
+ autoCapitalize="off"
1376
+ autoCorrect="off"
1377
+ className={cn(
1378
+ 'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
1379
+ 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
1380
+ '**:data-ref-text:cursor-default',
1381
+ stacked && 'pl-3',
1382
+ stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
1383
+ )}
1384
+ contentEditable={!disabled}
1385
+ data-placeholder={placeholder}
1386
+ data-slot={RICH_INPUT_SLOT}
1387
+ onBlur={() => window.setTimeout(closeTrigger, 80)}
1388
+ onCompositionEnd={event => {
1389
+ composingRef.current = false
1390
+
1391
+ // The input events fired *during* composition were skipped (they
1392
+ // carried uncommitted preedit text), and Chromium does NOT reliably
1393
+ // emit a trailing input event after compositionend on Windows IMEs.
1394
+ // Without flushing here, committed multi-character IME input (e.g.
1395
+ // Chinese "你好", Japanese, Korean) never reaches composer state, so
1396
+ // `hasComposerPayload` stays false and the send button stays hidden
1397
+ // until an unrelated edit forces a sync (#39614).
1398
+ flushEditorToDraft(event.currentTarget)
1399
+ }}
1400
+ onCompositionStart={() => {
1401
+ composingRef.current = true
1402
+ }}
1403
+ onDragOver={handleInputDragOver}
1404
+ onDrop={handleInputDrop}
1405
+ onFocus={() => markActiveComposer('main')}
1406
+ onInput={handleEditorInput}
1407
+ onKeyDown={handleEditorKeyDown}
1408
+ onKeyUp={handleEditorKeyUp}
1409
+ onMouseUp={refreshTrigger}
1410
+ onPaste={handlePaste}
1411
+ ref={editorRef}
1412
+ role="textbox"
1413
+ spellCheck="true"
1414
+ suppressContentEditableWarning
1415
+ />
1416
+ {/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
1417
+ so the composer-state binding (text + IME + paste + form-submit hookup)
1418
+ wires up. We render the real input UI ourselves above via the
1419
+ contentEditable, so the primitive is invisible (sr-only).
1420
+
1421
+ IMPORTANT: don't let it render its default <TextareaAutosize>. That
1422
+ component runs `useLayoutEffect(resizeTextarea)` on every value change
1423
+ and reads `node.scrollHeight` against a hidden measurement textarea,
1424
+ forcing two synchronous layouts per keystroke for an element the
1425
+ user can't see. Profiling 400-char synthetic typing showed >900ms
1426
+ cumulative cost in getHeight2/calculateNodeHeight alone (~2.3ms/key)
1427
+ on top of the per-keystroke React commit.
1428
+
1429
+ `asChild` swaps TextareaAutosize for a Radix Slot wrapping our
1430
+ plain <textarea>, which carries the binding but skips autosize. */}
1431
+ <ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
1432
+ <textarea aria-hidden className="sr-only" tabIndex={-1} />
1433
+ </ComposerPrimitive.Input>
1434
+ </div>
1435
+ )
1436
+
1437
+ return (
1438
+ <>
1439
+ <ComposerPrimitive.Unstable_TriggerPopoverRoot>
1440
+ <ComposerPrimitive.Root
1441
+ className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
1442
+ data-drag-active={dragActive ? '' : undefined}
1443
+ data-slot="composer-root"
1444
+ data-thread-scrolled-up={scrolledUp ? '' : undefined}
1445
+ onDragEnter={handleDragEnter}
1446
+ onDragLeave={handleDragLeave}
1447
+ onDragOver={handleDragOver}
1448
+ onDrop={handleDrop}
1449
+ onSubmit={e => {
1450
+ e.preventDefault()
1451
+
1452
+ if (composingRef.current) {
1453
+ return
1454
+ }
1455
+
1456
+ submitDraft()
1457
+ }}
1458
+ ref={composerRef}
1459
+ >
1460
+ {showHelpHint && <HelpHint />}
1461
+ {trigger && (
1462
+ <ComposerTriggerPopover
1463
+ activeIndex={triggerActive}
1464
+ items={triggerItems}
1465
+ kind={trigger.kind}
1466
+ loading={triggerLoading}
1467
+ onHover={setTriggerActive}
1468
+ onPick={replaceTriggerWithChip}
1469
+ />
1470
+ )}
1471
+ <SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
1472
+ {activeQueueSessionKey && queuedPrompts.length > 0 && (
1473
+ // Out of flow so the queue never inflates the composer's measured
1474
+ // height (that drives thread bottom padding → chat resizes on
1475
+ // queue). Overlaps -mb-2 onto the surface's top border for a shared
1476
+ // edge; capped + scrollable. Overlays the chat instead of pushing it.
1477
+ <div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
1478
+ <QueuePanel
1479
+ busy={busy}
1480
+ editingId={queueEdit?.entryId ?? null}
1481
+ entries={queuedPrompts}
1482
+ onDelete={id => {
1483
+ if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
1484
+ exitQueuedEdit('cancel')
1485
+ }
1486
+ }}
1487
+ onEdit={beginQueuedEdit}
1488
+ onSendNow={id => void sendQueuedNow(id)}
1489
+ />
1490
+ </div>
1491
+ )}
1492
+ <div
1493
+ className="pointer-events-none absolute inset-0 rounded-[inherit]"
1494
+ style={{ background: COMPOSER_FADE_BACKGROUND }}
1495
+ />
1496
+ <div className="relative w-full rounded-[inherit]">
1497
+ <div
1498
+ className={cn(
1499
+ 'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out',
1500
+ COMPOSER_DROP_FADE_CLASS,
1501
+ 'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
1502
+ 'group-has-data-[state=open]/composer:border-t-transparent',
1503
+ dragActive && COMPOSER_DROP_ACTIVE_CLASS
1504
+ )}
1505
+ data-slot="composer-surface"
1506
+ ref={composerSurfaceRef}
1507
+ >
1508
+ <div
1509
+ aria-hidden
1510
+ className={cn(
1511
+ 'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
1512
+ 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
1513
+ 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
1514
+ '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
1515
+ 'transition-[background-color] duration-150 ease-out',
1516
+ 'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
1517
+ 'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
1518
+ )}
1519
+ />
1520
+ <div
1521
+ className={cn(
1522
+ 'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
1523
+ scrolledUp
1524
+ ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
1525
+ : 'opacity-100'
1526
+ )}
1527
+ data-slot="composer-fade"
1528
+ >
1529
+ <VoiceActivity state={voiceActivityState} />
1530
+ <VoicePlaybackActivity />
1531
+ {queueEdit && editingQueuedPrompt && (
1532
+ <div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
1533
+ <div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
1534
+ {t.composer.editingQueuedInComposer}
1535
+ </div>
1536
+ <div className="flex shrink-0 items-center gap-1">
1537
+ <Button
1538
+ className="h-6 rounded-md px-2 text-[0.68rem]"
1539
+ onClick={() => exitQueuedEdit('cancel')}
1540
+ type="button"
1541
+ variant="ghost"
1542
+ >
1543
+ {t.common.cancel}
1544
+ </Button>
1545
+ <Button
1546
+ className="h-6 rounded-md px-2 text-[0.68rem]"
1547
+ onClick={() => exitQueuedEdit('save')}
1548
+ type="button"
1549
+ >
1550
+ {t.common.save}
1551
+ </Button>
1552
+ </div>
1553
+ </div>
1554
+ )}
1555
+ {attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
1556
+ <div
1557
+ className={cn(
1558
+ 'grid w-full',
1559
+ stacked
1560
+ ? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
1561
+ : 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
1562
+ )}
1563
+ >
1564
+ <div className="flex items-center [grid-area:menu]">{contextMenu}</div>
1565
+ <div className="min-w-0 [grid-area:input]">{input}</div>
1566
+ <div className="flex items-center justify-end [grid-area:controls]">{controls}</div>
1567
+ </div>
1568
+ </div>
1569
+ </div>
1570
+ </div>
1571
+ </ComposerPrimitive.Root>
1572
+ </ComposerPrimitive.Unstable_TriggerPopoverRoot>
1573
+
1574
+ <UrlDialog
1575
+ inputRef={urlInputRef}
1576
+ onChange={setUrlValue}
1577
+ onOpenChange={setUrlOpen}
1578
+ onSubmit={submitUrl}
1579
+ open={urlOpen}
1580
+ value={urlValue}
1581
+ />
1582
+ </>
1583
+ )
1584
+ }
1585
+
1586
+ export function ChatBarFallback() {
1587
+ return (
1588
+ <div
1589
+ className={cn(
1590
+ 'group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]',
1591
+ 'bg-linear-to-b from-transparent to-background/55'
1592
+ )}
1593
+ data-slot="composer-root"
1594
+ >
1595
+ <div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]">
1596
+ <div
1597
+ aria-hidden
1598
+ className={cn(
1599
+ 'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
1600
+ 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
1601
+ 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
1602
+ '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
1603
+ 'transition-[background-color] duration-150 ease-out',
1604
+ 'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
1605
+ 'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
1606
+ )}
1607
+ />
1608
+ </div>
1609
+ </div>
1610
+ )
1611
+ }