@nextclaw/ui 0.14.1-beta.1 → 0.14.2

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 (179) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/assets/{api-dQ6uQSZG.js → api-a14tR_z1.js} +7 -7
  3. package/dist/assets/channels-list-page-CFGkm4DT.js +8 -0
  4. package/dist/assets/chat-page-CEdavJXc.js +105 -0
  5. package/dist/assets/{config-split-page-CeFfbaqc.js → config-split-page-B5b9B0uE.js} +1 -1
  6. package/dist/assets/{confirm-dialog-C95Y4QPC.js → confirm-dialog-ClH_Ec7n.js} +1 -1
  7. package/dist/assets/{desktop-update-config-CcK6jiEb.js → desktop-update-config-DPNDnVpk.js} +1 -1
  8. package/dist/assets/{dist-DTGzeil6.js → dist-BtCS2Ucq.js} +1 -1
  9. package/dist/assets/{dist-fGogBmPA.js → dist-CLGGLm8b.js} +1 -1
  10. package/dist/assets/doc-browser-5nMWYKBD.js +1 -0
  11. package/dist/assets/doc-browser-BJRLhztv.js +1 -0
  12. package/dist/assets/doc-browser-context-D4JwMMxK.js +1 -0
  13. package/dist/assets/{ellipsis-D2F4KMiU.js → ellipsis-CtJXjIol.js} +1 -1
  14. package/dist/assets/{external-link-CcgtBK26.js → external-link-CdJiG13p.js} +1 -1
  15. package/dist/assets/index-BDN1zzMQ.js +105 -0
  16. package/dist/assets/index-g3uSZXoc.css +1 -0
  17. package/dist/assets/mcp-marketplace-page-BU0kIjhR.js +40 -0
  18. package/dist/assets/mcp-marketplace-page-sMNFSq_T.js +1 -0
  19. package/dist/assets/middleware-CkFT3R4v.js +1 -0
  20. package/dist/assets/model-config-page-D6hYXROd.js +1 -0
  21. package/dist/assets/plus-DPjb0piO.js +1 -0
  22. package/dist/assets/provider-scoped-model-input-DAX2HgmR.js +1 -0
  23. package/dist/assets/providers-config-page-BzAeaACA.js +1 -0
  24. package/dist/assets/{react-CTqdhFMk.js → react-C6cZgP44.js} +1 -1
  25. package/dist/assets/remote-B0G80uPr.js +1 -0
  26. package/dist/assets/rotate-cw-DnEB1Rju.js +1 -0
  27. package/dist/assets/runtime-config-page-C5xKaV3i.js +1 -0
  28. package/dist/assets/{save-C9fKWfVB.js → save-DKA0x4zk.js} +1 -1
  29. package/dist/assets/{search-D5FQ5q0X.js → search-BNlcoj5c.js} +1 -1
  30. package/dist/assets/{search-config-page-AMEJJNIS.js → search-config-page-Cq9uJ-N8.js} +1 -1
  31. package/dist/assets/{secrets-config-page-CcgJnvBO.js → secrets-config-page-CxSbZLBo.js} +2 -2
  32. package/dist/assets/{select-Cu5n0E-3.js → select-CvTlDZTQ.js} +1 -1
  33. package/dist/assets/{tag-chip-C5aOtjxu.js → tag-chip-8ffF2y2C.js} +1 -1
  34. package/dist/assets/use-config-DmT0o1gv.js +1 -0
  35. package/dist/assets/x-BiSSpgqB.js +1 -0
  36. package/dist/index.html +15 -14
  37. package/package.json +9 -9
  38. package/src/app/components/app-presenter-provider.tsx +11 -2
  39. package/src/app/components/layout/__tests__/app-layout.test.tsx +11 -2
  40. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +110 -79
  41. package/src/app/components/layout/app-layout.tsx +18 -4
  42. package/src/app/components/layout/sidebar-items.tsx +194 -71
  43. package/src/app/components/layout/sidebar-rail.styles.ts +15 -0
  44. package/src/app/components/layout/sidebar.tsx +340 -83
  45. package/src/app/managers/viewport-layout.manager.ts +10 -0
  46. package/src/app/stores/viewport-layout.store.ts +39 -3
  47. package/src/features/agents/components/__tests__/agents-page.test.tsx +62 -39
  48. package/src/features/agents/components/agents-page.tsx +10 -6
  49. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +145 -0
  50. package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +69 -762
  51. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +132 -0
  52. package/src/features/chat/components/conversation/chat-conversation-content.tsx +22 -15
  53. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +14 -15
  54. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +18 -18
  55. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +10 -4
  56. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +52 -11
  57. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +77 -12
  58. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +247 -0
  59. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +182 -21
  60. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +82 -25
  61. package/src/features/chat/components/layout/chat-sidebar.tsx +79 -93
  62. package/src/features/chat/components/providers/chat-presenter.provider.tsx +0 -6
  63. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +289 -0
  64. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +427 -0
  65. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +204 -0
  66. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-attachments.ts +66 -0
  67. package/src/features/chat/features/{input/hooks/use-chat-input-bar-query-state.ts → conversation/hooks/use-session-conversation-input-query.ts} +32 -17
  68. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +281 -0
  69. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +158 -0
  70. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +56 -0
  71. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +117 -0
  72. package/src/features/chat/features/input/hooks/use-chat-model-favorites.ts +2 -1
  73. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +77 -0
  74. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -0
  75. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +10 -0
  76. package/src/features/chat/features/input/input-surface-plugins/input-surface-search.utils.ts +108 -0
  77. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +108 -0
  78. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +161 -0
  79. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +22 -1
  80. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +26 -13
  81. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +7 -28
  82. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +6 -0
  83. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +26 -51
  84. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +2 -0
  85. package/src/features/chat/features/input/utils/ncp-chat-input-availability.utils.ts +5 -6
  86. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +47 -12
  87. package/src/features/chat/features/message/components/chat-message-list.container.tsx +93 -12
  88. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +31 -12
  89. package/src/features/chat/features/message/utils/chat-message-markdown-part.utils.ts +38 -0
  90. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +1 -1
  91. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +5 -4
  92. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +2 -2
  93. package/src/features/chat/features/session/hooks/__tests__/use-chat-session-project.test.tsx +3 -30
  94. package/src/features/chat/features/session/hooks/use-chat-session-project.ts +0 -5
  95. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +8 -0
  96. package/src/features/chat/features/session-type/components/__tests__/{chat-sidebar-create-menu.test.tsx → chat-session-type-menu.test.tsx} +5 -5
  97. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +29 -1
  98. package/src/features/chat/features/session-type/components/{chat-sidebar-create-menu.tsx → chat-session-type-menu.tsx} +13 -9
  99. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +17 -5
  100. package/src/features/chat/features/session-type/hooks/__tests__/use-chat-new-session-type-preference.test.tsx +152 -0
  101. package/src/features/chat/features/session-type/hooks/use-chat-new-session-type-preference.ts +159 -0
  102. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +49 -31
  103. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +2 -2
  104. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +21 -10
  105. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +2 -2
  106. package/src/features/chat/features/welcome/components/chat-welcome.tsx +2 -2
  107. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +54 -0
  108. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +44 -76
  109. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +5 -1
  110. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +7 -1
  111. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +40 -31
  112. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +59 -1
  113. package/src/features/chat/features/workspace/utils/chat-thread-workspace-session.utils.ts +109 -0
  114. package/src/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils.ts +44 -4
  115. package/src/features/chat/index.ts +0 -1
  116. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +51 -99
  117. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +135 -21
  118. package/src/features/chat/managers/chat-session-list.manager.ts +12 -43
  119. package/src/features/chat/managers/chat-thread.manager.ts +108 -34
  120. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +48 -1
  121. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +72 -0
  122. package/src/features/chat/pages/ncp-chat-page.tsx +0 -122
  123. package/src/features/chat/presenters/chat.presenter.ts +2 -19
  124. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +31 -0
  125. package/src/features/chat/stores/chat-thread.store.ts +42 -26
  126. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +24 -0
  127. package/src/features/marketplace/components/curated-shelves/marketplace-shelf-card.tsx +8 -3
  128. package/src/features/marketplace/components/marketplace-item-list-view.tsx +2 -2
  129. package/src/features/marketplace/components/marketplace-list-card.tsx +124 -98
  130. package/src/features/marketplace/components/marketplace-page-parts.tsx +11 -6
  131. package/src/features/panel-apps/hooks/use-panel-apps.ts +2 -1
  132. package/src/platforms/desktop/components/__tests__/desktop-app-shell.test.tsx +50 -10
  133. package/src/platforms/desktop/components/desktop-app-shell.tsx +34 -10
  134. package/src/platforms/desktop/components/desktop-window-chrome.tsx +41 -16
  135. package/src/shared/components/common/__tests__/agent-avatar.test.tsx +3 -1
  136. package/src/shared/components/common/agent-avatar.tsx +5 -2
  137. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +24 -1
  138. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +8 -4
  139. package/src/shared/lib/api/index.ts +1 -0
  140. package/src/shared/lib/api/preferences/preference-keys.config.ts +9 -0
  141. package/src/shared/lib/i18n/locales/en-US/chat.json +20 -2
  142. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  143. package/src/shared/lib/i18n/locales/en-US/marketplace.json +2 -0
  144. package/src/shared/lib/i18n/locales/zh-CN/chat.json +20 -2
  145. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  146. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +2 -0
  147. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +49 -0
  148. package/src/shared/lib/ui-document-title/index.ts +50 -21
  149. package/dist/assets/channels-list-page-DfpjB8XB.js +0 -8
  150. package/dist/assets/chat-page-D3QaEqjs.js +0 -105
  151. package/dist/assets/doc-browser-CuBTcBEZ.js +0 -1
  152. package/dist/assets/doc-browser-DVHhEb96.js +0 -1
  153. package/dist/assets/doc-browser-context-BfMXwnrz.js +0 -1
  154. package/dist/assets/index-ByJCbQqr.css +0 -1
  155. package/dist/assets/index-DjXqiloY.js +0 -103
  156. package/dist/assets/mcp-marketplace-page-98-V__3u.js +0 -1
  157. package/dist/assets/mcp-marketplace-page-BNq_PNmO.js +0 -40
  158. package/dist/assets/model-config-page-aAUkZkSX.js +0 -1
  159. package/dist/assets/plus-1Rxl5FlV.js +0 -1
  160. package/dist/assets/provider-scoped-model-input-we4_Z4gP.js +0 -1
  161. package/dist/assets/providers-config-page-CtMkPSyu.js +0 -1
  162. package/dist/assets/remote-fQ7N_r8u.js +0 -1
  163. package/dist/assets/rotate-cw-CzfIeAJj.js +0 -1
  164. package/dist/assets/runtime-config-page-TD1UrTbG.js +0 -1
  165. package/dist/assets/use-config-D8TESeo6.js +0 -1
  166. package/dist/assets/x-1qETdQ70.js +0 -1
  167. package/src/features/chat/components/conversation/chat-conversation-alerts.tsx +0 -37
  168. package/src/features/chat/features/input/components/__tests__/chat-attachment-upload-limit.test.ts +0 -38
  169. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +0 -496
  170. package/src/features/chat/features/message/utils/chat-message-inline-content.utils.ts +0 -95
  171. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +0 -448
  172. package/src/features/chat/managers/__tests__/chat-run-snapshot.manager.test.ts +0 -129
  173. package/src/features/chat/managers/__tests__/chat-run.manager.test.ts +0 -236
  174. package/src/features/chat/managers/__tests__/chat-session-preference-sync.manager.test.ts +0 -219
  175. package/src/features/chat/managers/chat-input.manager.ts +0 -430
  176. package/src/features/chat/managers/chat-run.manager.ts +0 -110
  177. package/src/features/chat/managers/chat-session-preference-sync.manager.ts +0 -142
  178. package/src/features/chat/stores/chat-input.store.ts +0 -104
  179. /package/dist/assets/{config-hints-CTqBnCDp.js → config-hints-BkYBc7z0.js} +0 -0
@@ -0,0 +1,427 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ } from 'react';
7
+ import { ChatInputBar, type ChatInputBarHandle, type ChatContextWindowIndicator } from '@nextclaw/agent-chat-ui';
8
+ import { isRuntimeDefaultModelValue } from '@nextclaw/shared';
9
+
10
+ import { useI18n } from '@/app/components/i18n-provider';
11
+ import { useViewportLayout } from '@/app/hooks/use-viewport-layout';
12
+ import { updateNcpSession, type SessionSkillEntryView, type ThinkingLevel } from '@/shared/lib/api';
13
+ import { t } from '@/shared/lib/i18n';
14
+ import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
15
+ import {
16
+ useChatInputSurfaceState,
17
+ } from '@/features/chat/features/input/hooks/use-chat-input-surface-state';
18
+ import { useChatModelFavorites } from '@/features/chat/features/input/hooks/use-chat-model-favorites';
19
+ import {
20
+ deriveChatComposerDraft,
21
+ deriveSelectedSkillsFromComposer,
22
+ pruneComposerAttachments,
23
+ syncComposerSkills,
24
+ } from '@/features/chat/features/input/utils/chat-composer-state.utils';
25
+ import {
26
+ buildModelStateHint,
27
+ type ChatModelRecord,
28
+ type ChatSkillRecord,
29
+ type ChatThinkingLevel,
30
+ } from '@/features/chat/features/input/utils/chat-input-bar.utils';
31
+ import {
32
+ hasNcpChatModelOptions,
33
+ isNcpChatComposerDisabled,
34
+ isNcpChatModelOptionsEmpty,
35
+ isNcpChatModelOptionsLoading,
36
+ } from '@/features/chat/features/input/utils/ncp-chat-input-availability.utils';
37
+ import {
38
+ chatRecentModelsManager,
39
+ CHAT_RECENT_MODELS_MIN_OPTIONS,
40
+ } from '@/features/chat/managers/chat-recent-models.manager';
41
+ import {
42
+ chatRecentSkillsManager,
43
+ CHAT_RECENT_SKILLS_MIN_OPTIONS,
44
+ } from '@/features/chat/managers/chat-recent-skills.manager';
45
+
46
+ import type { useSessionConversationController } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
47
+ import { useSessionConversationInputAttachments } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-attachments';
48
+ import type { useSessionConversationInputQuery } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-query';
49
+ import type {
50
+ SessionConversationInputActions,
51
+ SessionConversationInputSnapshot,
52
+ } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
53
+ import {
54
+ buildSessionConversationSkillPicker,
55
+ buildSessionConversationToolbarSelects,
56
+ resolveThinkingForConversationModel,
57
+ } from '@/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils';
58
+
59
+ type SessionConversationInputQuery = ReturnType<typeof useSessionConversationInputQuery>;
60
+ type SessionConversationController = ReturnType<typeof useSessionConversationController>;
61
+
62
+ function toSkillRecords(
63
+ snapshotRecords: SessionSkillEntryView[],
64
+ scopeLabels: Record<SessionSkillEntryView['scope'], string>,
65
+ ): ChatSkillRecord[] {
66
+ return snapshotRecords.map((record) => ({
67
+ key: record.ref,
68
+ label: record.name,
69
+ scopeLabel: scopeLabels[record.scope],
70
+ description: record.description,
71
+ descriptionZh: record.descriptionZh,
72
+ badgeLabel: scopeLabels[record.scope],
73
+ }));
74
+ }
75
+
76
+ function toModelRecords(snapshotModels: SessionConversationInputQuery['modelOptions']): ChatModelRecord[] {
77
+ return snapshotModels.map((model) => ({
78
+ value: model.value,
79
+ modelLabel: model.modelLabel,
80
+ providerLabel: model.providerLabel,
81
+ thinkingCapability: model.thinkingCapability
82
+ ? {
83
+ supported: model.thinkingCapability.supported as ChatThinkingLevel[],
84
+ default: (model.thinkingCapability.default as ChatThinkingLevel | null | undefined) ?? null,
85
+ }
86
+ : null,
87
+ }));
88
+ }
89
+
90
+ function useSessionConversationInputLabels(language: string) {
91
+ const skillScopeLabels = useMemo<Record<'builtin' | 'project' | 'workspace', string>>(() => {
92
+ void language;
93
+ return {
94
+ builtin: t('chatSkillScopeBuiltin'),
95
+ project: t('chatSkillScopeProject'),
96
+ workspace: t('chatSkillScopeWorkspace'),
97
+ };
98
+ }, [language]);
99
+ const slashTexts = useMemo(() => {
100
+ void language;
101
+ return {
102
+ slashSkillSubtitle: t('chatSlashTypeSkill'),
103
+ slashSkillSpecLabel: t('chatSlashSkillSpec'),
104
+ slashSkillScopeLabel: t('chatSlashSkillScope'),
105
+ noSkillDescription: t('chatSkillsPickerNoDescription'),
106
+ };
107
+ }, [language]);
108
+ return {
109
+ skillScopeLabels,
110
+ slashTexts,
111
+ recentModelsLabel: t('chatPickerRecentModels'),
112
+ allModelsLabel: t('chatPickerAllModels'),
113
+ favoriteModelsLabel: t('chatPickerFavoriteModels'),
114
+ modelSearchPlaceholder: t('chatModelSearchPlaceholder'),
115
+ modelSearchEmptyLabel: t('chatModelSearchEmpty'),
116
+ favoriteModelLabel: t('chatFavoriteModel'),
117
+ unfavoriteModelLabel: t('chatUnfavoriteModel'),
118
+ recentSkillsLabel: t('chatPickerRecent'),
119
+ allSkillsLabel: t('chatPickerAllSkills'),
120
+ };
121
+ }
122
+
123
+ function useSessionConversationInputCollections(params: {
124
+ modelOptions: SessionConversationInputQuery['modelOptions'];
125
+ skillRecords: SessionSkillEntryView[];
126
+ skillScopeLabels: Record<'builtin' | 'project' | 'workspace', string>;
127
+ }) {
128
+ const skillRecords = useMemo(
129
+ () => toSkillRecords(params.skillRecords, params.skillScopeLabels),
130
+ [params.skillRecords, params.skillScopeLabels],
131
+ );
132
+ const modelRecords = useMemo(() => toModelRecords(params.modelOptions), [params.modelOptions]);
133
+ return {
134
+ skillRecords,
135
+ modelRecords,
136
+ recentModelValues: chatRecentModelsManager.resolveVisible({
137
+ availableValues: modelRecords.map((option) => option.value),
138
+ minAvailableCount: CHAT_RECENT_MODELS_MIN_OPTIONS,
139
+ }),
140
+ recentSkillValues: chatRecentSkillsManager.resolveVisible({
141
+ availableValues: skillRecords.map((record) => record.key),
142
+ minAvailableCount: 0,
143
+ }),
144
+ recentSkillGroupValues: chatRecentSkillsManager.resolveVisible({
145
+ availableValues: skillRecords.map((record) => record.key),
146
+ minAvailableCount: CHAT_RECENT_SKILLS_MIN_OPTIONS,
147
+ }),
148
+ };
149
+ }
150
+
151
+ type SessionConversationInputProps = {
152
+ readonly contextWindow: ChatContextWindowIndicator | null;
153
+ readonly controller: SessionConversationController;
154
+ readonly inputActions: SessionConversationInputActions;
155
+ readonly inputQuery: SessionConversationInputQuery;
156
+ readonly inputSnapshot: SessionConversationInputSnapshot;
157
+ readonly surface?: 'default' | 'embedded';
158
+ };
159
+
160
+ export function SessionConversationInput(props: SessionConversationInputProps) {
161
+ const {
162
+ contextWindow,
163
+ controller,
164
+ inputActions,
165
+ inputQuery,
166
+ inputSnapshot,
167
+ surface = 'default',
168
+ } = props;
169
+ const presenter = usePresenter();
170
+ const { language } = useI18n();
171
+ const { isMobile } = useViewportLayout();
172
+ const inputBarRef = useRef<ChatInputBarHandle | null>(null);
173
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
174
+ const labels = useSessionConversationInputLabels(language);
175
+ const {
176
+ skillRecords,
177
+ modelRecords,
178
+ recentModelValues,
179
+ recentSkillValues,
180
+ recentSkillGroupValues,
181
+ } = useSessionConversationInputCollections({
182
+ modelOptions: inputQuery.modelOptions,
183
+ skillRecords: inputQuery.skillRecords,
184
+ skillScopeLabels: labels.skillScopeLabels,
185
+ });
186
+ const slashCommands = useMemo(() => {
187
+ const parentSessionKey = inputQuery.selectedSessionKey?.trim();
188
+ if (!parentSessionKey) {
189
+ return [];
190
+ }
191
+ return [
192
+ {
193
+ key: 'side-chat',
194
+ title: t('chatSlashCommandSideChatTitle', language),
195
+ description: t('chatSlashCommandSideChatDescription', language),
196
+ detailLines: [t('chatSlashCommandSideChatDetail', language)],
197
+ keywords: ['side', 'chat', 'child', 'branch', 'new'],
198
+ onSelect: () => presenter.chatThreadManager.openSideChatDraft(parentSessionKey),
199
+ },
200
+ ];
201
+ }, [
202
+ inputQuery.selectedSessionKey,
203
+ language,
204
+ presenter.chatThreadManager,
205
+ ]);
206
+ const { inputSurfaceState, setInputSurfaceTrigger } = useChatInputSurfaceState({
207
+ commands: slashCommands,
208
+ isSkillsLoading: inputQuery.isSkillsLoading,
209
+ itemTexts: {
210
+ slashTexts: labels.slashTexts,
211
+ },
212
+ language,
213
+ onSelectSkill: chatRecentSkillsManager.remember,
214
+ recentSkillValues,
215
+ skillRecords,
216
+ });
217
+ const modelRecordValues = useMemo(
218
+ () => modelRecords.map((option) => option.value),
219
+ [modelRecords],
220
+ );
221
+ const {
222
+ favoriteModelValues,
223
+ setModelFavorite,
224
+ } = useChatModelFavorites(modelRecordValues);
225
+ const selectedModel =
226
+ inputSnapshot.selectedModel ??
227
+ inputQuery.fallbackPreferredModel ??
228
+ inputQuery.defaultModel ??
229
+ '';
230
+ const selectedThinkingLevel = (
231
+ inputSnapshot.selectedThinkingLevel ??
232
+ inputQuery.fallbackPreferredThinking ??
233
+ null
234
+ ) as ChatThinkingLevel | null;
235
+ const availabilitySnapshot = {
236
+ isProviderStateResolved: inputQuery.isProviderStateResolved,
237
+ modelOptions: inputQuery.modelOptions,
238
+ sessionTypeUnavailable: inputQuery.sessionTypeState.sessionTypeUnavailable,
239
+ };
240
+ const hasModelOptions = hasNcpChatModelOptions(availabilitySnapshot);
241
+ const isModelOptionsLoading = isNcpChatModelOptionsLoading(availabilitySnapshot);
242
+ const isModelOptionsEmpty = isNcpChatModelOptionsEmpty(availabilitySnapshot);
243
+ const inputDisabled = isNcpChatComposerDisabled(availabilitySnapshot);
244
+ const attachmentSupported = true;
245
+ const textareaPlaceholder = isModelOptionsEmpty
246
+ ? t('chatModelNoOptions')
247
+ : t(isMobile ? 'chatInputPlaceholderCompact' : 'chatInputPlaceholder');
248
+ const selectedModelOption = modelRecords.find((option) => option.value === selectedModel);
249
+ const thinkingSupportedLevels = selectedModelOption?.thinkingCapability?.supported ?? [];
250
+ const { handleFilesAdd, handleFileInputChange } = useSessionConversationInputAttachments({
251
+ attachmentSupported,
252
+ inputBarRef,
253
+ addAttachments: inputActions.addAttachments,
254
+ });
255
+ const syncSessionPreferences = useCallback((patch: {
256
+ preferredModel?: string | null;
257
+ preferredThinking?: ThinkingLevel | null;
258
+ }) => {
259
+ if (!inputQuery.selectedSessionKey || !inputQuery.selectedSession) {
260
+ return;
261
+ }
262
+ void updateNcpSession(inputQuery.selectedSessionKey, patch).catch(() => undefined);
263
+ }, [inputQuery.selectedSession, inputQuery.selectedSessionKey]);
264
+ const handleNodesChange = useCallback((nodes: SessionConversationInputSnapshot['nodes']) => {
265
+ const nextNodes = [...nodes];
266
+ const attachments = pruneComposerAttachments(nextNodes, inputSnapshot.attachments);
267
+ inputActions.update({
268
+ nodes: nextNodes,
269
+ attachments,
270
+ text: deriveChatComposerDraft(nextNodes),
271
+ selectedSkills: deriveSelectedSkillsFromComposer(nextNodes),
272
+ sendError: null,
273
+ });
274
+ }, [inputActions, inputSnapshot.attachments]);
275
+ const handleModelChange = useCallback((value: string) => {
276
+ const nextThinkingLevel = resolveThinkingForConversationModel(
277
+ modelRecords.find((option) => option.value === value),
278
+ selectedThinkingLevel,
279
+ );
280
+ inputActions.update({
281
+ selectedModel: value,
282
+ selectedThinkingLevel: nextThinkingLevel,
283
+ sendError: null,
284
+ });
285
+ chatRecentModelsManager.remember(value, {
286
+ namespace: inputQuery.sessionTypeState.selectedSessionType,
287
+ });
288
+ if (!isRuntimeDefaultModelValue(value)) {
289
+ chatRecentModelsManager.remember(value);
290
+ }
291
+ syncSessionPreferences({
292
+ preferredModel: isRuntimeDefaultModelValue(value) ? null : value,
293
+ preferredThinking: nextThinkingLevel,
294
+ });
295
+ }, [
296
+ inputActions,
297
+ inputQuery.sessionTypeState.selectedSessionType,
298
+ modelRecords,
299
+ selectedThinkingLevel,
300
+ syncSessionPreferences,
301
+ ]);
302
+ const handleThinkingChange = useCallback((value: ChatThinkingLevel | null) => {
303
+ inputActions.setSelectedThinkingLevel(value);
304
+ syncSessionPreferences({ preferredThinking: value });
305
+ }, [inputActions, syncSessionPreferences]);
306
+ const handleSelectedSkillsChange = useCallback((next: string[]) => {
307
+ const previousSelection = inputSnapshot.selectedSkills;
308
+ next
309
+ .filter((value) => !previousSelection.includes(value))
310
+ .forEach((value) => chatRecentSkillsManager.remember(value));
311
+ const nextNodes = syncComposerSkills(
312
+ [...inputSnapshot.nodes],
313
+ next,
314
+ inputQuery.skillRecords.map((record) => ({
315
+ ref: record.ref,
316
+ name: record.name,
317
+ })),
318
+ );
319
+ handleNodesChange(nextNodes);
320
+ }, [handleNodesChange, inputQuery.skillRecords, inputSnapshot.nodes, inputSnapshot.selectedSkills]);
321
+
322
+ useEffect(() => {
323
+ if (!inputSnapshot.composerFocusRequestId) {
324
+ return;
325
+ }
326
+ inputBarRef.current?.focusComposerAtEnd([...inputSnapshot.nodes]);
327
+ inputActions.consumeComposerFocusRequest();
328
+ }, [
329
+ inputActions,
330
+ inputSnapshot.composerFocusRequestId,
331
+ inputSnapshot.nodes,
332
+ ]);
333
+
334
+ const toolbarSelects = buildSessionConversationToolbarSelects({
335
+ allModelsLabel: labels.allModelsLabel,
336
+ favoriteModelLabel: labels.favoriteModelLabel,
337
+ favoriteModelValues,
338
+ favoriteModelsLabel: labels.favoriteModelsLabel,
339
+ hasModelOptions,
340
+ isModelOptionsLoading,
341
+ modelRecords,
342
+ modelSearchEmptyLabel: labels.modelSearchEmptyLabel,
343
+ modelSearchPlaceholder: labels.modelSearchPlaceholder,
344
+ onFavoriteModelToggle: setModelFavorite,
345
+ onModelChange: handleModelChange,
346
+ onThinkingChange: handleThinkingChange,
347
+ recentModelValues,
348
+ recentModelsLabel: labels.recentModelsLabel,
349
+ selectedModel,
350
+ selectedThinkingLevel,
351
+ thinkingSupportedLevels,
352
+ thinkingDefaultLevel: selectedModelOption?.thinkingCapability?.default ?? null,
353
+ unfavoriteModelLabel: labels.unfavoriteModelLabel,
354
+ });
355
+ const skillPicker = buildSessionConversationSkillPicker({
356
+ allSkillsLabel: labels.allSkillsLabel,
357
+ onSelectedKeysChange: handleSelectedSkillsChange,
358
+ recentSkillGroupValues,
359
+ recentSkillValues,
360
+ recentSkillsLabel: labels.recentSkillsLabel,
361
+ isSkillsLoading: inputQuery.isSkillsLoading,
362
+ skillRecords,
363
+ selectedSkills: inputSnapshot.selectedSkills,
364
+ });
365
+
366
+ return (
367
+ <>
368
+ <ChatInputBar
369
+ ref={inputBarRef}
370
+ surface={surface}
371
+ composer={{
372
+ nodes: [...inputSnapshot.nodes],
373
+ placeholder: textareaPlaceholder,
374
+ disabled: inputDisabled,
375
+ onNodesChange: handleNodesChange,
376
+ onFilesAdd: handleFilesAdd,
377
+ inputSurfaceTriggerSpecs: inputSurfaceState.triggerSpecs,
378
+ onInputSurfaceTriggerChange: setInputSurfaceTrigger,
379
+ }}
380
+ inputSurface={inputSurfaceState.panel ?? undefined}
381
+ hint={buildModelStateHint({
382
+ isModelOptionsLoading,
383
+ isModelOptionsEmpty,
384
+ onGoToProviders: presenter.chatUiManager.goToProviders,
385
+ texts: {
386
+ noModelOptionsLabel: t('chatModelNoOptions'),
387
+ configureProviderLabel: t('chatGoConfigureProvider'),
388
+ },
389
+ })}
390
+ toolbar={{
391
+ selects: toolbarSelects,
392
+ accessories: [
393
+ {
394
+ key: 'attach',
395
+ label: t('chatInputAttach'),
396
+ icon: 'paperclip' as const,
397
+ iconOnly: true,
398
+ disabled: !attachmentSupported || inputDisabled || controller.isSending,
399
+ onClick: () => fileInputRef.current?.click(),
400
+ },
401
+ ],
402
+ skillPicker,
403
+ actions: {
404
+ sendError: inputSnapshot.sendError,
405
+ isSending: controller.isSending,
406
+ canStopGeneration: controller.canStopGeneration,
407
+ sendDisabled: controller.sendDisabled,
408
+ stopDisabled: controller.stopDisabled,
409
+ stopHint: t('chatStopUnavailable'),
410
+ sendButtonLabel: t('chatSend'),
411
+ stopButtonLabel: t('chatStop'),
412
+ contextWindow,
413
+ onSend: controller.send,
414
+ onStop: controller.stop,
415
+ },
416
+ }}
417
+ />
418
+ <input
419
+ ref={fileInputRef}
420
+ type="file"
421
+ multiple
422
+ className="hidden"
423
+ onChange={handleFileInputChange}
424
+ />
425
+ </>
426
+ );
427
+ }
@@ -0,0 +1,204 @@
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
+ const resolveProjectRootForSend = (params: {
59
+ readonly inputSnapshot: SessionConversationInputSnapshot;
60
+ readonly inputQuery: SessionConversationInputQuery;
61
+ readonly sessionKey: string | null;
62
+ }): string | null => {
63
+ const { inputSnapshot, inputQuery, sessionKey } = params;
64
+ if (!sessionKey) {
65
+ return inputSnapshot.pendingProjectRoot ?? inputQuery.defaultProjectRoot ?? null;
66
+ }
67
+ return inputQuery.selectedSession?.projectRoot ?? null;
68
+ };
69
+
70
+ export function useSessionConversationController(params: UseSessionConversationControllerParams) {
71
+ const {
72
+ agent,
73
+ inputSnapshot,
74
+ inputQuery,
75
+ isRuntimeBlocked,
76
+ materializationContext,
77
+ selectedAgentId,
78
+ sessionKey,
79
+ onSessionMaterialized,
80
+ resetComposer,
81
+ restoreComposer,
82
+ setSendError,
83
+ } = params;
84
+ const parts = useMemo(
85
+ () => deriveNcpMessagePartsFromComposer([...inputSnapshot.nodes], inputSnapshot.attachments),
86
+ [inputSnapshot.attachments, inputSnapshot.nodes],
87
+ );
88
+ const hasSendableDraft = hasSendableMessagePart(parts);
89
+ const isSending = agent.isSending || agent.isRunning;
90
+ const sendDisabled = isNcpChatSendDisabled({
91
+ snapshot: {
92
+ isProviderStateResolved: inputQuery.isProviderStateResolved,
93
+ modelOptions: inputQuery.modelOptions,
94
+ sessionTypeUnavailable: inputQuery.sessionTypeState.sessionTypeUnavailable,
95
+ },
96
+ hasSendableDraft,
97
+ isRuntimeBlocked,
98
+ }) || isSending;
99
+ const send = useCallback(async () => {
100
+ const currentParts = deriveNcpMessagePartsFromComposer(
101
+ [...inputSnapshot.nodes],
102
+ inputSnapshot.attachments,
103
+ );
104
+ if (
105
+ isNcpChatSendDisabled({
106
+ snapshot: {
107
+ isProviderStateResolved: inputQuery.isProviderStateResolved,
108
+ modelOptions: inputQuery.modelOptions,
109
+ sessionTypeUnavailable: inputQuery.sessionTypeState.sessionTypeUnavailable,
110
+ },
111
+ hasSendableDraft: hasSendableMessagePart(currentParts),
112
+ isRuntimeBlocked,
113
+ }) ||
114
+ isSending
115
+ ) {
116
+ return;
117
+ }
118
+ const composerSnapshot = {
119
+ text: inputSnapshot.text,
120
+ nodes: inputSnapshot.nodes,
121
+ selectedSkills: inputSnapshot.selectedSkills,
122
+ skillRecords: inputSnapshot.skillRecords,
123
+ attachments: inputSnapshot.attachments,
124
+ };
125
+ const metadata = buildChatRunMetadata({
126
+ agentId: materializationContext ? undefined : selectedAgentId,
127
+ model: materializationContext
128
+ ? resolveModelForSend(inputSnapshot.selectedModel)
129
+ : resolveModelForSend(inputSnapshot.selectedModel ?? inputQuery.fallbackPreferredModel ?? inputQuery.defaultModel),
130
+ thinkingLevel: materializationContext
131
+ ? inputSnapshot.selectedThinkingLevel ?? undefined
132
+ : inputSnapshot.selectedThinkingLevel ?? inputQuery.fallbackPreferredThinking ?? undefined,
133
+ sessionType: materializationContext
134
+ ? undefined
135
+ : inputQuery.sessionTypeState.selectedSessionType,
136
+ projectRoot: materializationContext
137
+ ? inputSnapshot.pendingProjectRoot
138
+ : resolveProjectRootForSend({ inputSnapshot, inputQuery, sessionKey }),
139
+ requestedSkills: [...inputSnapshot.selectedSkills],
140
+ composerNodes: [...inputSnapshot.nodes],
141
+ sessionMaterialization: materializationContext
142
+ ? {
143
+ kind: materializationContext.kind,
144
+ parentSessionId: materializationContext.parentSessionKey,
145
+ inheritContext: materializationContext.inheritContext,
146
+ }
147
+ : null,
148
+ });
149
+ const envelope = buildNcpRequestEnvelope({
150
+ sessionId: sessionKey ?? undefined,
151
+ text: inputSnapshot.text.trim(),
152
+ attachments: [...inputSnapshot.attachments],
153
+ parts: currentParts,
154
+ metadata,
155
+ });
156
+ if (!envelope) {
157
+ return;
158
+ }
159
+ resetComposer();
160
+ setSendError(null);
161
+ try {
162
+ const handle = await agent.send(envelope);
163
+ const materializedSessionKey =
164
+ handle?.sessionId?.trim() ||
165
+ agent.snapshot.activeRun?.sessionId?.trim() ||
166
+ null;
167
+ if (!sessionKey && materializedSessionKey) {
168
+ onSessionMaterialized?.(materializedSessionKey);
169
+ }
170
+ } catch (error) {
171
+ restoreComposer(composerSnapshot);
172
+ const message = error instanceof Error ? error.message : String(error);
173
+ setSendError(message);
174
+ throw error;
175
+ }
176
+ }, [
177
+ agent,
178
+ inputQuery,
179
+ inputSnapshot,
180
+ isRuntimeBlocked,
181
+ isSending,
182
+ materializationContext,
183
+ onSessionMaterialized,
184
+ resetComposer,
185
+ restoreComposer,
186
+ selectedAgentId,
187
+ sessionKey,
188
+ setSendError,
189
+ ]);
190
+
191
+ const stop = useCallback(async () => {
192
+ await agent.abort();
193
+ }, [agent]);
194
+
195
+ return {
196
+ canStopGeneration: agent.isRunning,
197
+ hasSendableDraft,
198
+ isSending,
199
+ send,
200
+ sendDisabled,
201
+ stop,
202
+ stopDisabled: !agent.isRunning,
203
+ };
204
+ }
@@ -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
+ }