@nextclaw/ui 0.14.1 → 0.14.3

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 (224) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/{api-DPcvCLfa.js → api-BP_8IaxB.js} +7 -7
  3. package/dist/assets/channels-list-page-DEh5Jjqx.js +8 -0
  4. package/dist/assets/chat-page-BRB0N5WW.js +105 -0
  5. package/dist/assets/config-split-page-CRhlZ_pH.js +1 -0
  6. package/dist/assets/{confirm-dialog-CWh5FfK2.js → confirm-dialog-DsivFJ4r.js} +2 -2
  7. package/dist/assets/{desktop-update-config-DsRhBizi.js → desktop-update-config-B0w9p2ep.js} +1 -1
  8. package/dist/assets/{dist-DPrgcEC0.js → dist-CikkhOlq.js} +1 -1
  9. package/dist/assets/{dist-DsE4OsSS.js → dist-PVA_xL1D.js} +1 -1
  10. package/dist/assets/doc-browser-BoNJ7-tE.js +1 -0
  11. package/dist/assets/doc-browser-CdJ_d4ik.js +1 -0
  12. package/dist/assets/doc-browser-context-MTw4EvKd.js +1 -0
  13. package/dist/assets/{ellipsis-B81FStLm.js → ellipsis-y6vDtq4M.js} +1 -1
  14. package/dist/assets/{external-link-C_dmG-WJ.js → external-link-DVJIQf_G.js} +1 -1
  15. package/dist/assets/index-BiE-K8-q.css +1 -0
  16. package/dist/assets/index-D9BAE_L_.js +105 -0
  17. package/dist/assets/mcp-marketplace-page-B1SZNY5m.js +40 -0
  18. package/dist/assets/mcp-marketplace-page-Cq_Tw42T.js +1 -0
  19. package/dist/assets/middleware-CkFT3R4v.js +1 -0
  20. package/dist/assets/model-config-page-DotaTUHb.js +1 -0
  21. package/dist/assets/plus-BbZqquJe.js +1 -0
  22. package/dist/assets/provider-scoped-model-input-DsFRuWJH.js +1 -0
  23. package/dist/assets/providers-config-page-DW4tQSLN.js +1 -0
  24. package/dist/assets/{react-sZz5HSCm.js → react-VNqT5Zp7.js} +1 -1
  25. package/dist/assets/remote-B8TiUfwW.js +1 -0
  26. package/dist/assets/rotate-cw-DjHDTkCv.js +1 -0
  27. package/dist/assets/runtime-config-page-S6ErRHR8.js +1 -0
  28. package/dist/assets/{save-MzjoiHri.js → save-BOJ477H-.js} +1 -1
  29. package/dist/assets/{search-B29dE4GN.js → search-BEti5beA.js} +1 -1
  30. package/dist/assets/{search-config-page-Dl5sZzob.js → search-config-page-DGj5am03.js} +1 -1
  31. package/dist/assets/{secrets-config-page-C_l-IFCK.js → secrets-config-page--IzrIOid.js} +2 -2
  32. package/dist/assets/{select-BKmh05hd.js → select-hoj8dK4K.js} +2 -2
  33. package/dist/assets/{tag-chip-BS_7Ueom.js → tag-chip-T8-h-die.js} +1 -1
  34. package/dist/assets/use-config-CjRV2hwL.js +1 -0
  35. package/dist/assets/x-D9PXs_xP.js +1 -0
  36. package/dist/index.html +70 -19
  37. package/index.html +55 -5
  38. package/package.json +9 -9
  39. package/src/app/components/app-presenter-provider.tsx +11 -2
  40. package/src/app/components/layout/__tests__/app-layout.test.tsx +11 -2
  41. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +110 -79
  42. package/src/app/components/layout/app-layout.tsx +18 -4
  43. package/src/app/components/layout/header.tsx +3 -3
  44. package/src/app/components/layout/page-layout.tsx +2 -2
  45. package/src/app/components/layout/runtime-status-entry.tsx +7 -7
  46. package/src/app/components/layout/sidebar-items.tsx +194 -71
  47. package/src/app/components/layout/sidebar-rail.styles.ts +15 -0
  48. package/src/app/components/layout/sidebar.tsx +340 -83
  49. package/src/app/managers/viewport-layout.manager.ts +10 -0
  50. package/src/app/stores/viewport-layout.store.ts +39 -3
  51. package/src/app/styles/design-system.css +388 -26
  52. package/src/app/test/vitest-setup.ts +31 -0
  53. package/src/features/agents/components/__tests__/agents-page.test.tsx +62 -39
  54. package/src/features/agents/components/agents-page.tsx +10 -6
  55. package/src/features/chat/components/chat-sidebar-list-mode-switch.tsx +3 -3
  56. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +145 -0
  57. package/src/features/chat/components/conversation/__tests__/chat-conversation-header.test.tsx +4 -2
  58. package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +69 -762
  59. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +132 -0
  60. package/src/features/chat/components/conversation/chat-conversation-content.tsx +22 -15
  61. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +15 -16
  62. package/src/features/chat/components/conversation/chat-conversation-header.tsx +7 -6
  63. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +19 -19
  64. package/src/features/chat/components/conversation/chat-conversation-skeleton.tsx +13 -13
  65. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +10 -4
  66. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +52 -11
  67. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +18 -16
  68. package/src/features/chat/components/layout/chat-page-shell.tsx +1 -1
  69. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +247 -0
  70. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +93 -20
  71. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +93 -36
  72. package/src/features/chat/components/layout/chat-sidebar.tsx +60 -93
  73. package/src/features/chat/components/providers/chat-presenter.provider.tsx +0 -6
  74. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +225 -0
  75. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +199 -0
  76. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +308 -0
  77. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +439 -0
  78. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +194 -0
  79. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-attachments.ts +66 -0
  80. package/src/features/chat/features/{input/hooks/use-chat-input-bar-query-state.ts → conversation/hooks/use-session-conversation-input-query.ts} +51 -20
  81. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +281 -0
  82. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +158 -0
  83. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +56 -0
  84. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +117 -0
  85. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +77 -0
  86. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -0
  87. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +10 -0
  88. package/src/features/chat/features/input/input-surface-plugins/input-surface-search.utils.ts +108 -0
  89. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +108 -0
  90. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +161 -0
  91. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +22 -1
  92. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +26 -13
  93. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +7 -28
  94. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +6 -0
  95. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +26 -51
  96. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +2 -0
  97. package/src/features/chat/features/input/utils/ncp-chat-input-availability.utils.ts +5 -6
  98. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +47 -12
  99. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +7 -7
  100. package/src/features/chat/features/message/components/chat-message-list.container.tsx +99 -18
  101. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +31 -12
  102. package/src/features/chat/features/message/utils/chat-message-markdown-part.utils.ts +38 -0
  103. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +1 -1
  104. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +7 -7
  105. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +6 -6
  106. package/src/features/chat/features/session/components/session-context-icon.tsx +2 -2
  107. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-badge.test.tsx +2 -2
  108. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +1 -1
  109. package/src/features/chat/features/session/components/session-header/chat-session-header-menu-item.tsx +1 -1
  110. package/src/features/chat/features/session/components/session-header/chat-session-metadata-dialog.tsx +1 -1
  111. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +3 -3
  112. package/src/features/chat/features/session/components/session-run-badge.tsx +1 -1
  113. package/src/features/chat/features/session/hooks/__tests__/use-chat-session-project.test.tsx +3 -30
  114. package/src/features/chat/features/session/hooks/use-chat-session-project.ts +0 -5
  115. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +8 -0
  116. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-menu.test.tsx +2 -2
  117. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +2 -0
  118. package/src/features/chat/features/session-type/components/chat-session-type-menu.tsx +2 -2
  119. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +2 -2
  120. package/src/features/chat/features/session-type/hooks/use-chat-session-type-state.ts +10 -2
  121. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +49 -31
  122. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +2 -9
  123. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +21 -10
  124. package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +3 -3
  125. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +4 -4
  126. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +14 -19
  127. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +5 -5
  128. package/src/features/chat/features/welcome/components/chat-welcome.tsx +6 -6
  129. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +6 -8
  130. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +1 -16
  131. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +54 -0
  132. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +44 -76
  133. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +5 -1
  134. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +7 -1
  135. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +40 -31
  136. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +59 -1
  137. package/src/features/chat/features/workspace/utils/chat-thread-workspace-session.utils.ts +109 -0
  138. package/src/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils.ts +44 -4
  139. package/src/features/chat/index.ts +0 -1
  140. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +51 -99
  141. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +135 -21
  142. package/src/features/chat/managers/chat-session-list.manager.ts +12 -43
  143. package/src/features/chat/managers/chat-thread.manager.ts +108 -34
  144. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +48 -1
  145. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +72 -0
  146. package/src/features/chat/pages/ncp-chat-page.tsx +0 -122
  147. package/src/features/chat/presenters/chat.presenter.ts +2 -19
  148. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +31 -0
  149. package/src/features/chat/stores/chat-thread.store.ts +42 -26
  150. package/src/features/panel-apps/components/panel-app-list-item.tsx +8 -8
  151. package/src/features/panel-apps/components/panel-app-toolbar.tsx +4 -4
  152. package/src/features/panel-apps/components/panel-apps-list.tsx +20 -20
  153. package/src/features/panel-apps/hooks/use-panel-apps.ts +2 -1
  154. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +70 -6
  155. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +10 -5
  156. package/src/features/service-apps/components/service-action-authorization-dialog.tsx +8 -8
  157. package/src/features/service-apps/components/service-apps-panel.tsx +24 -24
  158. package/src/features/settings/components/config/provider-advanced-settings-section.tsx +5 -5
  159. package/src/features/settings/components/config/provider-auth-section.tsx +7 -7
  160. package/src/features/settings/components/config/provider-form.tsx +10 -10
  161. package/src/features/settings/components/config/provider-models-section.tsx +16 -16
  162. package/src/features/settings/components/config/provider-pill-selector.tsx +1 -1
  163. package/src/features/settings/pages/providers-config-page.tsx +17 -17
  164. package/src/platforms/desktop/components/__tests__/desktop-app-shell.test.tsx +50 -10
  165. package/src/platforms/desktop/components/desktop-app-shell.tsx +34 -10
  166. package/src/platforms/desktop/components/desktop-window-chrome.tsx +41 -16
  167. package/src/shared/components/config-split-page.tsx +11 -11
  168. package/src/shared/components/doc-browser/doc-browser-home-page.tsx +2 -2
  169. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +4 -4
  170. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +4 -4
  171. package/src/shared/components/doc-browser/doc-browser.tsx +3 -3
  172. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +1 -1
  173. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -1
  174. package/src/shared/components/ui/button.tsx +4 -4
  175. package/src/shared/components/ui/card.tsx +4 -4
  176. package/src/shared/components/ui/dialog.tsx +5 -5
  177. package/src/shared/components/ui/input.tsx +1 -1
  178. package/src/shared/components/ui/label.tsx +1 -1
  179. package/src/shared/components/ui/popover.tsx +1 -1
  180. package/src/shared/components/ui/select.tsx +3 -3
  181. package/src/shared/components/ui/switch.tsx +3 -3
  182. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +24 -1
  183. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +8 -4
  184. package/src/shared/components/ui/tabs.tsx +4 -4
  185. package/src/shared/components/ui/textarea.tsx +1 -1
  186. package/src/shared/lib/i18n/locales/en-US/chat.json +20 -2
  187. package/src/shared/lib/i18n/locales/en-US/core.json +9 -2
  188. package/src/shared/lib/i18n/locales/zh-CN/chat.json +20 -2
  189. package/src/shared/lib/i18n/locales/zh-CN/core.json +9 -2
  190. package/src/shared/lib/theme/index.ts +94 -52
  191. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +49 -0
  192. package/src/shared/lib/ui-document-title/index.ts +50 -21
  193. package/dist/assets/channels-list-page-D6jtziu4.js +0 -8
  194. package/dist/assets/chat-page-BBnNJoKG.js +0 -105
  195. package/dist/assets/config-split-page-BCJhqdK9.js +0 -1
  196. package/dist/assets/doc-browser-C8xOF_9F.js +0 -1
  197. package/dist/assets/doc-browser-DgQedeAn.js +0 -1
  198. package/dist/assets/doc-browser-context-B46tQGmO.js +0 -1
  199. package/dist/assets/index-7o1WdUbU.js +0 -103
  200. package/dist/assets/index-DQWmEWzk.css +0 -1
  201. package/dist/assets/mcp-marketplace-page-CWZN7ohK.js +0 -1
  202. package/dist/assets/mcp-marketplace-page-Dzull_4-.js +0 -40
  203. package/dist/assets/model-config-page-Cv0O3BIj.js +0 -1
  204. package/dist/assets/plus-Bnt56oqG.js +0 -1
  205. package/dist/assets/provider-scoped-model-input-Duuh7i3h.js +0 -1
  206. package/dist/assets/providers-config-page-CPDz0Lxc.js +0 -1
  207. package/dist/assets/remote-B0LMs8u7.js +0 -1
  208. package/dist/assets/rotate-cw-BfyG_KEF.js +0 -1
  209. package/dist/assets/runtime-config-page-q0fvv8ME.js +0 -1
  210. package/dist/assets/use-config-DZkBWIdL.js +0 -1
  211. package/dist/assets/x-CW2KO-Hz.js +0 -1
  212. package/src/features/chat/components/conversation/chat-conversation-alerts.tsx +0 -37
  213. package/src/features/chat/features/input/components/__tests__/chat-attachment-upload-limit.test.ts +0 -38
  214. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +0 -496
  215. package/src/features/chat/features/message/utils/chat-message-inline-content.utils.ts +0 -95
  216. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +0 -448
  217. package/src/features/chat/managers/__tests__/chat-run-snapshot.manager.test.ts +0 -129
  218. package/src/features/chat/managers/__tests__/chat-run.manager.test.ts +0 -236
  219. package/src/features/chat/managers/__tests__/chat-session-preference-sync.manager.test.ts +0 -219
  220. package/src/features/chat/managers/chat-input.manager.ts +0 -430
  221. package/src/features/chat/managers/chat-run.manager.ts +0 -110
  222. package/src/features/chat/managers/chat-session-preference-sync.manager.ts +0 -142
  223. package/src/features/chat/stores/chat-input.store.ts +0 -104
  224. /package/dist/assets/{config-hints-CTqBnCDp.js → config-hints-BkYBc7z0.js} +0 -0
@@ -0,0 +1,439 @@
1
+ import {
2
+ memo,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ } from 'react';
8
+ import {
9
+ ChatInputBar,
10
+ type ChatContextWindowIndicator,
11
+ type ChatInputBarHandle,
12
+ } from '@nextclaw/agent-chat-ui';
13
+ import { isRuntimeDefaultModelValue } from '@nextclaw/shared';
14
+
15
+ import { useI18n } from '@/app/components/i18n-provider';
16
+ import { useViewportLayout } from '@/app/hooks/use-viewport-layout';
17
+ import { updateNcpSession, type SessionSkillEntryView, type ThinkingLevel } from '@/shared/lib/api';
18
+ import { t } from '@/shared/lib/i18n';
19
+ import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
20
+ import {
21
+ useChatInputSurfaceState,
22
+ } from '@/features/chat/features/input/hooks/use-chat-input-surface-state';
23
+ import { useChatModelFavorites } from '@/features/chat/features/input/hooks/use-chat-model-favorites';
24
+ import {
25
+ deriveChatComposerDraft,
26
+ deriveSelectedSkillsFromComposer,
27
+ pruneComposerAttachments,
28
+ syncComposerSkills,
29
+ } from '@/features/chat/features/input/utils/chat-composer-state.utils';
30
+ import {
31
+ buildModelStateHint,
32
+ type ChatModelRecord,
33
+ type ChatSkillRecord,
34
+ type ChatThinkingLevel,
35
+ } from '@/features/chat/features/input/utils/chat-input-bar.utils';
36
+ import {
37
+ hasNcpChatModelOptions,
38
+ isNcpChatComposerDisabled,
39
+ isNcpChatModelOptionsEmpty,
40
+ isNcpChatModelOptionsLoading,
41
+ } from '@/features/chat/features/input/utils/ncp-chat-input-availability.utils';
42
+ import {
43
+ chatRecentModelsManager,
44
+ CHAT_RECENT_MODELS_MIN_OPTIONS,
45
+ } from '@/features/chat/managers/chat-recent-models.manager';
46
+ import {
47
+ chatRecentSkillsManager,
48
+ CHAT_RECENT_SKILLS_MIN_OPTIONS,
49
+ } from '@/features/chat/managers/chat-recent-skills.manager';
50
+
51
+ import { useSessionConversationInputAttachments } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-attachments';
52
+ import type { useSessionConversationInputQuery } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-query';
53
+ import type {
54
+ SessionConversationInputActions,
55
+ SessionConversationInputSnapshot,
56
+ } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
57
+ import {
58
+ buildSessionConversationSkillPicker,
59
+ buildSessionConversationToolbarSelects,
60
+ resolveThinkingForConversationModel,
61
+ } from '@/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils';
62
+
63
+ type SessionConversationInputQuery = ReturnType<typeof useSessionConversationInputQuery>;
64
+
65
+ export type SessionConversationInputController = {
66
+ readonly canStopGeneration: boolean;
67
+ readonly isSending: boolean;
68
+ readonly sendDisabled: boolean;
69
+ readonly stopDisabled: boolean;
70
+ readonly send: () => Promise<void> | void;
71
+ readonly stop: () => Promise<void> | void;
72
+ };
73
+
74
+ function toSkillRecords(
75
+ snapshotRecords: SessionSkillEntryView[],
76
+ scopeLabels: Record<SessionSkillEntryView['scope'], string>,
77
+ ): ChatSkillRecord[] {
78
+ return snapshotRecords.map((record) => ({
79
+ key: record.ref,
80
+ label: record.name,
81
+ scopeLabel: scopeLabels[record.scope],
82
+ description: record.description,
83
+ descriptionZh: record.descriptionZh,
84
+ badgeLabel: scopeLabels[record.scope],
85
+ }));
86
+ }
87
+
88
+ function toModelRecords(snapshotModels: SessionConversationInputQuery['modelOptions']): ChatModelRecord[] {
89
+ return snapshotModels.map((model) => ({
90
+ value: model.value,
91
+ modelLabel: model.modelLabel,
92
+ providerLabel: model.providerLabel,
93
+ thinkingCapability: model.thinkingCapability
94
+ ? {
95
+ supported: model.thinkingCapability.supported as ChatThinkingLevel[],
96
+ default: (model.thinkingCapability.default as ChatThinkingLevel | null | undefined) ?? null,
97
+ }
98
+ : null,
99
+ }));
100
+ }
101
+
102
+ function useSessionConversationInputLabels(language: string) {
103
+ const skillScopeLabels = useMemo<Record<'builtin' | 'project' | 'workspace', string>>(() => {
104
+ void language;
105
+ return {
106
+ builtin: t('chatSkillScopeBuiltin'),
107
+ project: t('chatSkillScopeProject'),
108
+ workspace: t('chatSkillScopeWorkspace'),
109
+ };
110
+ }, [language]);
111
+ const slashTexts = useMemo(() => {
112
+ void language;
113
+ return {
114
+ slashSkillSubtitle: t('chatSlashTypeSkill'),
115
+ slashSkillSpecLabel: t('chatSlashSkillSpec'),
116
+ slashSkillScopeLabel: t('chatSlashSkillScope'),
117
+ noSkillDescription: t('chatSkillsPickerNoDescription'),
118
+ };
119
+ }, [language]);
120
+ return {
121
+ skillScopeLabels,
122
+ slashTexts,
123
+ recentModelsLabel: t('chatPickerRecentModels'),
124
+ allModelsLabel: t('chatPickerAllModels'),
125
+ favoriteModelsLabel: t('chatPickerFavoriteModels'),
126
+ modelSearchPlaceholder: t('chatModelSearchPlaceholder'),
127
+ modelSearchEmptyLabel: t('chatModelSearchEmpty'),
128
+ favoriteModelLabel: t('chatFavoriteModel'),
129
+ unfavoriteModelLabel: t('chatUnfavoriteModel'),
130
+ recentSkillsLabel: t('chatPickerRecent'),
131
+ allSkillsLabel: t('chatPickerAllSkills'),
132
+ };
133
+ }
134
+
135
+ function useSessionConversationInputCollections(params: {
136
+ modelOptions: SessionConversationInputQuery['modelOptions'];
137
+ skillRecords: SessionSkillEntryView[];
138
+ skillScopeLabels: Record<'builtin' | 'project' | 'workspace', string>;
139
+ }) {
140
+ const skillRecords = useMemo(
141
+ () => toSkillRecords(params.skillRecords, params.skillScopeLabels),
142
+ [params.skillRecords, params.skillScopeLabels],
143
+ );
144
+ const modelRecords = useMemo(() => toModelRecords(params.modelOptions), [params.modelOptions]);
145
+ return {
146
+ skillRecords,
147
+ modelRecords,
148
+ recentModelValues: chatRecentModelsManager.resolveVisible({
149
+ availableValues: modelRecords.map((option) => option.value),
150
+ minAvailableCount: CHAT_RECENT_MODELS_MIN_OPTIONS,
151
+ }),
152
+ recentSkillValues: chatRecentSkillsManager.resolveVisible({
153
+ availableValues: skillRecords.map((record) => record.key),
154
+ minAvailableCount: 0,
155
+ }),
156
+ recentSkillGroupValues: chatRecentSkillsManager.resolveVisible({
157
+ availableValues: skillRecords.map((record) => record.key),
158
+ minAvailableCount: CHAT_RECENT_SKILLS_MIN_OPTIONS,
159
+ }),
160
+ };
161
+ }
162
+
163
+ type SessionConversationInputProps = {
164
+ readonly contextWindow: ChatContextWindowIndicator | null;
165
+ readonly controller: SessionConversationInputController;
166
+ readonly inputActions: SessionConversationInputActions;
167
+ readonly inputQuery: SessionConversationInputQuery;
168
+ readonly inputSnapshot: SessionConversationInputSnapshot;
169
+ readonly surface?: 'default' | 'embedded';
170
+ };
171
+
172
+ export const SessionConversationInput = memo(function SessionConversationInput(props: SessionConversationInputProps) {
173
+ const {
174
+ contextWindow,
175
+ controller,
176
+ inputActions,
177
+ inputQuery,
178
+ inputSnapshot,
179
+ surface = 'default',
180
+ } = props;
181
+ const presenter = usePresenter();
182
+ const { language } = useI18n();
183
+ const { isMobile } = useViewportLayout();
184
+ const inputBarRef = useRef<ChatInputBarHandle | null>(null);
185
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
186
+ const labels = useSessionConversationInputLabels(language);
187
+ const {
188
+ skillRecords,
189
+ modelRecords,
190
+ recentModelValues,
191
+ recentSkillValues,
192
+ recentSkillGroupValues,
193
+ } = useSessionConversationInputCollections({
194
+ modelOptions: inputQuery.modelOptions,
195
+ skillRecords: inputQuery.skillRecords,
196
+ skillScopeLabels: labels.skillScopeLabels,
197
+ });
198
+ const slashCommands = useMemo(() => {
199
+ const parentSessionKey = inputQuery.selectedSessionKey?.trim();
200
+ if (!parentSessionKey) {
201
+ return [];
202
+ }
203
+ return [
204
+ {
205
+ key: 'side-chat',
206
+ title: t('chatSlashCommandSideChatTitle', language),
207
+ description: t('chatSlashCommandSideChatDescription', language),
208
+ detailLines: [t('chatSlashCommandSideChatDetail', language)],
209
+ keywords: ['side', 'chat', 'child', 'branch', 'new'],
210
+ onSelect: () => presenter.chatThreadManager.openSideChatDraft(parentSessionKey),
211
+ },
212
+ ];
213
+ }, [
214
+ inputQuery.selectedSessionKey,
215
+ language,
216
+ presenter.chatThreadManager,
217
+ ]);
218
+ const { inputSurfaceState, setInputSurfaceTrigger } = useChatInputSurfaceState({
219
+ commands: slashCommands,
220
+ isSkillsLoading: inputQuery.isSkillsLoading,
221
+ itemTexts: {
222
+ slashTexts: labels.slashTexts,
223
+ },
224
+ language,
225
+ onSelectSkill: chatRecentSkillsManager.remember,
226
+ recentSkillValues,
227
+ skillRecords,
228
+ });
229
+ const modelRecordValues = useMemo(
230
+ () => modelRecords.map((option) => option.value),
231
+ [modelRecords],
232
+ );
233
+ const {
234
+ favoriteModelValues,
235
+ setModelFavorite,
236
+ } = useChatModelFavorites(modelRecordValues);
237
+ const selectedModel =
238
+ inputSnapshot.selectedModel ??
239
+ inputQuery.fallbackPreferredModel ??
240
+ inputQuery.defaultModel ??
241
+ '';
242
+ const selectedThinkingLevel = (
243
+ inputSnapshot.selectedThinkingLevel ??
244
+ inputQuery.fallbackPreferredThinking ??
245
+ null
246
+ ) as ChatThinkingLevel | null;
247
+ const availabilitySnapshot = {
248
+ isProviderStateResolved: inputQuery.isProviderStateResolved,
249
+ modelOptions: inputQuery.modelOptions,
250
+ sessionTypeUnavailable: inputQuery.sessionTypeState.sessionTypeUnavailable,
251
+ };
252
+ const hasModelOptions = hasNcpChatModelOptions(availabilitySnapshot);
253
+ const isModelOptionsLoading = isNcpChatModelOptionsLoading(availabilitySnapshot);
254
+ const isModelOptionsEmpty = isNcpChatModelOptionsEmpty(availabilitySnapshot);
255
+ const inputDisabled = isNcpChatComposerDisabled(availabilitySnapshot);
256
+ const attachmentSupported = true;
257
+ const textareaPlaceholder = isModelOptionsEmpty
258
+ ? t('chatModelNoOptions')
259
+ : t(isMobile ? 'chatInputPlaceholderCompact' : 'chatInputPlaceholder');
260
+ const selectedModelOption = modelRecords.find((option) => option.value === selectedModel);
261
+ const thinkingSupportedLevels = selectedModelOption?.thinkingCapability?.supported ?? [];
262
+ const { handleFilesAdd, handleFileInputChange } = useSessionConversationInputAttachments({
263
+ attachmentSupported,
264
+ inputBarRef,
265
+ addAttachments: inputActions.addAttachments,
266
+ });
267
+ const syncSessionPreferences = useCallback((patch: {
268
+ preferredModel?: string | null;
269
+ preferredThinking?: ThinkingLevel | null;
270
+ }) => {
271
+ if (!inputQuery.selectedSessionKey || !inputQuery.selectedSession) {
272
+ return;
273
+ }
274
+ void updateNcpSession(inputQuery.selectedSessionKey, patch).catch(() => undefined);
275
+ }, [inputQuery.selectedSession, inputQuery.selectedSessionKey]);
276
+ const handleNodesChange = useCallback((nodes: SessionConversationInputSnapshot['nodes']) => {
277
+ const nextNodes = [...nodes];
278
+ const attachments = pruneComposerAttachments(nextNodes, inputSnapshot.attachments);
279
+ inputActions.update({
280
+ nodes: nextNodes,
281
+ attachments,
282
+ text: deriveChatComposerDraft(nextNodes),
283
+ selectedSkills: deriveSelectedSkillsFromComposer(nextNodes),
284
+ sendError: null,
285
+ });
286
+ }, [inputActions, inputSnapshot.attachments]);
287
+ const handleModelChange = useCallback((value: string) => {
288
+ const nextThinkingLevel = resolveThinkingForConversationModel(
289
+ modelRecords.find((option) => option.value === value),
290
+ selectedThinkingLevel,
291
+ );
292
+ inputActions.update({
293
+ selectedModel: value,
294
+ selectedThinkingLevel: nextThinkingLevel,
295
+ sendError: null,
296
+ });
297
+ chatRecentModelsManager.remember(value, {
298
+ namespace: inputQuery.sessionTypeState.selectedSessionType,
299
+ });
300
+ if (!isRuntimeDefaultModelValue(value)) {
301
+ chatRecentModelsManager.remember(value);
302
+ }
303
+ syncSessionPreferences({
304
+ preferredModel: isRuntimeDefaultModelValue(value) ? null : value,
305
+ preferredThinking: nextThinkingLevel,
306
+ });
307
+ }, [
308
+ inputActions,
309
+ inputQuery.sessionTypeState.selectedSessionType,
310
+ modelRecords,
311
+ selectedThinkingLevel,
312
+ syncSessionPreferences,
313
+ ]);
314
+ const handleThinkingChange = useCallback((value: ChatThinkingLevel | null) => {
315
+ inputActions.setSelectedThinkingLevel(value);
316
+ syncSessionPreferences({ preferredThinking: value });
317
+ }, [inputActions, syncSessionPreferences]);
318
+ const handleSelectedSkillsChange = useCallback((next: string[]) => {
319
+ const previousSelection = inputSnapshot.selectedSkills;
320
+ next
321
+ .filter((value) => !previousSelection.includes(value))
322
+ .forEach((value) => chatRecentSkillsManager.remember(value));
323
+ const nextNodes = syncComposerSkills(
324
+ [...inputSnapshot.nodes],
325
+ next,
326
+ inputQuery.skillRecords.map((record) => ({
327
+ ref: record.ref,
328
+ name: record.name,
329
+ })),
330
+ );
331
+ handleNodesChange(nextNodes);
332
+ }, [handleNodesChange, inputQuery.skillRecords, inputSnapshot.nodes, inputSnapshot.selectedSkills]);
333
+
334
+ useEffect(() => {
335
+ if (!inputSnapshot.composerFocusRequestId) {
336
+ return;
337
+ }
338
+ inputBarRef.current?.focusComposerAtEnd([...inputSnapshot.nodes]);
339
+ inputActions.consumeComposerFocusRequest();
340
+ }, [
341
+ inputActions,
342
+ inputSnapshot.composerFocusRequestId,
343
+ inputSnapshot.nodes,
344
+ ]);
345
+
346
+ const toolbarSelects = buildSessionConversationToolbarSelects({
347
+ allModelsLabel: labels.allModelsLabel,
348
+ favoriteModelLabel: labels.favoriteModelLabel,
349
+ favoriteModelValues,
350
+ favoriteModelsLabel: labels.favoriteModelsLabel,
351
+ hasModelOptions,
352
+ isModelOptionsLoading,
353
+ modelRecords,
354
+ modelSearchEmptyLabel: labels.modelSearchEmptyLabel,
355
+ modelSearchPlaceholder: labels.modelSearchPlaceholder,
356
+ onFavoriteModelToggle: setModelFavorite,
357
+ onModelChange: handleModelChange,
358
+ onThinkingChange: handleThinkingChange,
359
+ recentModelValues,
360
+ recentModelsLabel: labels.recentModelsLabel,
361
+ selectedModel,
362
+ selectedThinkingLevel,
363
+ thinkingSupportedLevels,
364
+ thinkingDefaultLevel: selectedModelOption?.thinkingCapability?.default ?? null,
365
+ unfavoriteModelLabel: labels.unfavoriteModelLabel,
366
+ });
367
+ const skillPicker = buildSessionConversationSkillPicker({
368
+ allSkillsLabel: labels.allSkillsLabel,
369
+ onSelectedKeysChange: handleSelectedSkillsChange,
370
+ recentSkillGroupValues,
371
+ recentSkillValues,
372
+ recentSkillsLabel: labels.recentSkillsLabel,
373
+ isSkillsLoading: inputQuery.isSkillsLoading,
374
+ skillRecords,
375
+ selectedSkills: inputSnapshot.selectedSkills,
376
+ });
377
+
378
+ return (
379
+ <>
380
+ <ChatInputBar
381
+ ref={inputBarRef}
382
+ surface={surface}
383
+ composer={{
384
+ nodes: [...inputSnapshot.nodes],
385
+ placeholder: textareaPlaceholder,
386
+ disabled: inputDisabled,
387
+ onNodesChange: handleNodesChange,
388
+ onFilesAdd: handleFilesAdd,
389
+ inputSurfaceTriggerSpecs: inputSurfaceState.triggerSpecs,
390
+ onInputSurfaceTriggerChange: setInputSurfaceTrigger,
391
+ }}
392
+ inputSurface={inputSurfaceState.panel ?? undefined}
393
+ hint={buildModelStateHint({
394
+ isModelOptionsLoading,
395
+ isModelOptionsEmpty,
396
+ onGoToProviders: presenter.chatUiManager.goToProviders,
397
+ texts: {
398
+ noModelOptionsLabel: t('chatModelNoOptions'),
399
+ configureProviderLabel: t('chatGoConfigureProvider'),
400
+ },
401
+ })}
402
+ toolbar={{
403
+ selects: toolbarSelects,
404
+ accessories: [
405
+ {
406
+ key: 'attach',
407
+ label: t('chatInputAttach'),
408
+ icon: 'paperclip' as const,
409
+ iconOnly: true,
410
+ disabled: !attachmentSupported || inputDisabled || controller.isSending,
411
+ onClick: () => fileInputRef.current?.click(),
412
+ },
413
+ ],
414
+ skillPicker,
415
+ actions: {
416
+ sendError: inputSnapshot.sendError,
417
+ isSending: controller.isSending,
418
+ canStopGeneration: controller.canStopGeneration,
419
+ sendDisabled: controller.sendDisabled,
420
+ stopDisabled: controller.stopDisabled,
421
+ stopHint: t('chatStopUnavailable'),
422
+ sendButtonLabel: t('chatSend'),
423
+ stopButtonLabel: t('chatStop'),
424
+ contextWindow,
425
+ onSend: controller.send,
426
+ onStop: controller.stop,
427
+ },
428
+ }}
429
+ />
430
+ <input
431
+ ref={fileInputRef}
432
+ type="file"
433
+ multiple
434
+ className="hidden"
435
+ onChange={handleFileInputChange}
436
+ />
437
+ </>
438
+ );
439
+ });
@@ -0,0 +1,194 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import { buildNcpRequestEnvelope } from '@nextclaw/ncp-react';
3
+ import type { NcpAgentSendEnvelope, NcpMessage, NcpRunHandle } from '@nextclaw/ncp';
4
+
5
+ import { deriveNcpMessagePartsFromComposer } from '@/features/chat/features/input/utils/chat-composer-state.utils';
6
+ import { isNcpChatSendDisabled } from '@/features/chat/features/input/utils/ncp-chat-input-availability.utils';
7
+ import { buildChatRunMetadata } from '@/features/chat/features/session/utils/chat-run-metadata.utils';
8
+ import type { SessionConversationInputSnapshot } from './use-session-conversation-input-state';
9
+ import type { useSessionConversationInputQuery } from './use-session-conversation-input-query';
10
+
11
+ type SessionConversationInputQuery = ReturnType<typeof useSessionConversationInputQuery>;
12
+
13
+ type SessionConversationAgent = {
14
+ readonly isHydrating: boolean;
15
+ readonly isSending: boolean;
16
+ readonly isRunning: boolean;
17
+ readonly visibleMessages: readonly NcpMessage[];
18
+ readonly snapshot: {
19
+ readonly activeRun?: {
20
+ readonly sessionId?: string | null;
21
+ } | null;
22
+ };
23
+ readonly send: (envelope: NcpAgentSendEnvelope) => Promise<NcpRunHandle | null>;
24
+ readonly abort: () => Promise<void>;
25
+ };
26
+
27
+ export type SessionConversationMaterializationContext = {
28
+ readonly kind: 'child';
29
+ readonly parentSessionKey: string;
30
+ readonly inheritContext: true;
31
+ };
32
+
33
+ type UseSessionConversationControllerParams = {
34
+ readonly agent: SessionConversationAgent;
35
+ readonly inputSnapshot: SessionConversationInputSnapshot;
36
+ readonly inputQuery: SessionConversationInputQuery;
37
+ readonly isRuntimeBlocked: boolean;
38
+ readonly materializationContext?: SessionConversationMaterializationContext | null;
39
+ readonly selectedAgentId: string;
40
+ readonly sessionKey: string | null;
41
+ readonly onSessionMaterialized?: (sessionKey: string) => void;
42
+ readonly resetComposer: () => void;
43
+ readonly restoreComposer: (snapshot: Pick<
44
+ SessionConversationInputSnapshot,
45
+ 'text' | 'nodes' | 'selectedSkills' | 'skillRecords' | 'attachments'
46
+ >) => void;
47
+ readonly setSendError: (message: string | null) => void;
48
+ };
49
+
50
+ const hasSendableMessagePart = (parts: ReturnType<typeof deriveNcpMessagePartsFromComposer>): boolean =>
51
+ parts.some((part) => part.type !== 'text' || part.text.trim().length > 0);
52
+
53
+ const resolveModelForSend = (value: string | null | undefined): string | undefined => {
54
+ const trimmed = value?.trim();
55
+ return trimmed || undefined;
56
+ };
57
+
58
+ export function useSessionConversationController(params: UseSessionConversationControllerParams) {
59
+ const {
60
+ agent,
61
+ inputSnapshot,
62
+ inputQuery,
63
+ isRuntimeBlocked,
64
+ materializationContext,
65
+ selectedAgentId,
66
+ sessionKey,
67
+ onSessionMaterialized,
68
+ resetComposer,
69
+ restoreComposer,
70
+ setSendError,
71
+ } = params;
72
+ const parts = useMemo(
73
+ () => deriveNcpMessagePartsFromComposer([...inputSnapshot.nodes], inputSnapshot.attachments),
74
+ [inputSnapshot.attachments, inputSnapshot.nodes],
75
+ );
76
+ const hasSendableDraft = hasSendableMessagePart(parts);
77
+ const isSending = agent.isSending || agent.isRunning;
78
+ const sendDisabled = isNcpChatSendDisabled({
79
+ snapshot: {
80
+ isProviderStateResolved: inputQuery.isProviderStateResolved,
81
+ modelOptions: inputQuery.modelOptions,
82
+ sessionTypeUnavailable: inputQuery.sessionTypeState.sessionTypeUnavailable,
83
+ },
84
+ hasSendableDraft,
85
+ isRuntimeBlocked,
86
+ }) || isSending;
87
+ const send = useCallback(async () => {
88
+ const currentParts = deriveNcpMessagePartsFromComposer(
89
+ [...inputSnapshot.nodes],
90
+ inputSnapshot.attachments,
91
+ );
92
+ if (
93
+ isNcpChatSendDisabled({
94
+ snapshot: {
95
+ isProviderStateResolved: inputQuery.isProviderStateResolved,
96
+ modelOptions: inputQuery.modelOptions,
97
+ sessionTypeUnavailable: inputQuery.sessionTypeState.sessionTypeUnavailable,
98
+ },
99
+ hasSendableDraft: hasSendableMessagePart(currentParts),
100
+ isRuntimeBlocked,
101
+ }) ||
102
+ isSending
103
+ ) {
104
+ return;
105
+ }
106
+ const composerSnapshot = {
107
+ text: inputSnapshot.text,
108
+ nodes: inputSnapshot.nodes,
109
+ selectedSkills: inputSnapshot.selectedSkills,
110
+ skillRecords: inputSnapshot.skillRecords,
111
+ attachments: inputSnapshot.attachments,
112
+ };
113
+ const metadata = buildChatRunMetadata({
114
+ agentId: materializationContext ? undefined : selectedAgentId,
115
+ model: materializationContext
116
+ ? resolveModelForSend(inputSnapshot.selectedModel)
117
+ : resolveModelForSend(inputSnapshot.selectedModel ?? inputQuery.fallbackPreferredModel ?? inputQuery.defaultModel),
118
+ thinkingLevel: materializationContext
119
+ ? inputSnapshot.selectedThinkingLevel ?? undefined
120
+ : inputSnapshot.selectedThinkingLevel ?? inputQuery.fallbackPreferredThinking ?? undefined,
121
+ sessionType: materializationContext
122
+ ? undefined
123
+ : inputQuery.sessionTypeState.selectedSessionType,
124
+ projectRoot: materializationContext
125
+ ? inputSnapshot.pendingProjectRoot
126
+ : sessionKey
127
+ ? inputQuery.selectedSession?.projectRoot ?? null
128
+ : inputSnapshot.pendingProjectRoot,
129
+ requestedSkills: [...inputSnapshot.selectedSkills],
130
+ composerNodes: [...inputSnapshot.nodes],
131
+ sessionMaterialization: materializationContext
132
+ ? {
133
+ kind: materializationContext.kind,
134
+ parentSessionId: materializationContext.parentSessionKey,
135
+ inheritContext: materializationContext.inheritContext,
136
+ }
137
+ : null,
138
+ });
139
+ const envelope = buildNcpRequestEnvelope({
140
+ sessionId: sessionKey ?? undefined,
141
+ text: inputSnapshot.text.trim(),
142
+ attachments: [...inputSnapshot.attachments],
143
+ parts: currentParts,
144
+ metadata,
145
+ });
146
+ if (!envelope) {
147
+ return;
148
+ }
149
+ resetComposer();
150
+ setSendError(null);
151
+ try {
152
+ const handle = await agent.send(envelope);
153
+ const materializedSessionKey =
154
+ handle?.sessionId?.trim() ||
155
+ agent.snapshot.activeRun?.sessionId?.trim() ||
156
+ null;
157
+ if (!sessionKey && materializedSessionKey) {
158
+ onSessionMaterialized?.(materializedSessionKey);
159
+ }
160
+ } catch (error) {
161
+ restoreComposer(composerSnapshot);
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ setSendError(message);
164
+ throw error;
165
+ }
166
+ }, [
167
+ agent,
168
+ inputQuery,
169
+ inputSnapshot,
170
+ isRuntimeBlocked,
171
+ isSending,
172
+ materializationContext,
173
+ onSessionMaterialized,
174
+ resetComposer,
175
+ restoreComposer,
176
+ selectedAgentId,
177
+ sessionKey,
178
+ setSendError,
179
+ ]);
180
+
181
+ const stop = useCallback(async () => {
182
+ await agent.abort();
183
+ }, [agent]);
184
+
185
+ return {
186
+ canStopGeneration: agent.isRunning,
187
+ hasSendableDraft,
188
+ isSending,
189
+ send,
190
+ sendDisabled,
191
+ stop,
192
+ stopDisabled: !agent.isRunning,
193
+ };
194
+ }
@@ -0,0 +1,66 @@
1
+ import { useCallback, type ChangeEvent, type RefObject } from 'react';
2
+ import type { ChatInputBarHandle } from '@nextclaw/agent-chat-ui';
3
+ import {
4
+ DEFAULT_NCP_ATTACHMENT_MAX_BYTES,
5
+ uploadFilesAsNcpDraftAttachments,
6
+ } from '@nextclaw/ncp-react';
7
+ import { toast } from 'sonner';
8
+
9
+ import { uploadNcpAssets } from '@/shared/lib/api';
10
+ import { t } from '@/shared/lib/i18n';
11
+ import type { SessionConversationInputActions } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
12
+
13
+ type UseSessionConversationInputAttachmentsParams = {
14
+ readonly attachmentSupported: boolean;
15
+ readonly inputBarRef: RefObject<ChatInputBarHandle | null>;
16
+ readonly addAttachments: SessionConversationInputActions['addAttachments'];
17
+ };
18
+
19
+ export function useSessionConversationInputAttachments(params: UseSessionConversationInputAttachmentsParams) {
20
+ const { addAttachments, attachmentSupported, inputBarRef } = params;
21
+ const showAttachmentError = useCallback((reason: 'unsupported-type' | 'too-large' | 'read-failed') => {
22
+ if (reason === 'unsupported-type') {
23
+ toast.error(t('chatInputAttachmentUnsupported'));
24
+ return;
25
+ }
26
+ if (reason === 'too-large') {
27
+ toast.error(
28
+ t('chatInputAttachmentTooLarge').replace(
29
+ '{maxMb}',
30
+ String(DEFAULT_NCP_ATTACHMENT_MAX_BYTES / (1024 * 1024)),
31
+ ),
32
+ );
33
+ return;
34
+ }
35
+ toast.error(t('chatInputAttachmentReadFailed'));
36
+ }, []);
37
+ const handleFilesAdd = useCallback(async (files: File[]) => {
38
+ if (!attachmentSupported || files.length === 0) {
39
+ return;
40
+ }
41
+ const result = await uploadFilesAsNcpDraftAttachments(files, { uploadBatch: uploadNcpAssets });
42
+ if (result.attachments.length > 0) {
43
+ const insertedAttachments = addAttachments(result.attachments);
44
+ if (insertedAttachments.length > 0) {
45
+ inputBarRef.current?.insertFileTokens(
46
+ insertedAttachments.map((attachment) => ({
47
+ tokenKey: attachment.id,
48
+ label: attachment.name,
49
+ })),
50
+ );
51
+ }
52
+ }
53
+ if (result.rejected.length > 0) {
54
+ showAttachmentError(result.rejected[0].reason);
55
+ }
56
+ }, [addAttachments, attachmentSupported, inputBarRef, showAttachmentError]);
57
+
58
+ return {
59
+ handleFilesAdd,
60
+ handleFileInputChange: useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
61
+ const files = Array.from(event.target.files ?? []);
62
+ event.currentTarget.value = '';
63
+ await handleFilesAdd(files);
64
+ }, [handleFilesAdd]),
65
+ };
66
+ }