@nextclaw/ui 0.14.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 (165) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/assets/{api-DPcvCLfa.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-BCJhqdK9.js → config-split-page-B5b9B0uE.js} +1 -1
  6. package/dist/assets/{confirm-dialog-CWh5FfK2.js → confirm-dialog-ClH_Ec7n.js} +1 -1
  7. package/dist/assets/{desktop-update-config-DsRhBizi.js → desktop-update-config-DPNDnVpk.js} +1 -1
  8. package/dist/assets/{dist-DPrgcEC0.js → dist-BtCS2Ucq.js} +1 -1
  9. package/dist/assets/{dist-DsE4OsSS.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-B81FStLm.js → ellipsis-CtJXjIol.js} +1 -1
  14. package/dist/assets/{external-link-C_dmG-WJ.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-sZz5HSCm.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-MzjoiHri.js → save-DKA0x4zk.js} +1 -1
  29. package/dist/assets/{search-B29dE4GN.js → search-BNlcoj5c.js} +1 -1
  30. package/dist/assets/{search-config-page-Dl5sZzob.js → search-config-page-Cq9uJ-N8.js} +1 -1
  31. package/dist/assets/{secrets-config-page-C_l-IFCK.js → secrets-config-page-CxSbZLBo.js} +2 -2
  32. package/dist/assets/{select-BKmh05hd.js → select-CvTlDZTQ.js} +1 -1
  33. package/dist/assets/{tag-chip-BS_7Ueom.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 +16 -14
  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 +80 -7
  60. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +82 -25
  61. package/src/features/chat/components/layout/chat-sidebar.tsx +59 -92
  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/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +77 -0
  73. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -0
  74. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +10 -0
  75. package/src/features/chat/features/input/input-surface-plugins/input-surface-search.utils.ts +108 -0
  76. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +108 -0
  77. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +161 -0
  78. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +22 -1
  79. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +26 -13
  80. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +7 -28
  81. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +6 -0
  82. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +26 -51
  83. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +2 -0
  84. package/src/features/chat/features/input/utils/ncp-chat-input-availability.utils.ts +5 -6
  85. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +47 -12
  86. package/src/features/chat/features/message/components/chat-message-list.container.tsx +93 -12
  87. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +31 -12
  88. package/src/features/chat/features/message/utils/chat-message-markdown-part.utils.ts +38 -0
  89. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +1 -1
  90. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
  91. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +2 -2
  92. package/src/features/chat/features/session/hooks/__tests__/use-chat-session-project.test.tsx +3 -30
  93. package/src/features/chat/features/session/hooks/use-chat-session-project.ts +0 -5
  94. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +8 -0
  95. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-menu.test.tsx +2 -2
  96. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +2 -0
  97. package/src/features/chat/features/session-type/components/chat-session-type-menu.tsx +2 -2
  98. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +2 -2
  99. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +49 -31
  100. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +2 -2
  101. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +21 -10
  102. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +2 -2
  103. package/src/features/chat/features/welcome/components/chat-welcome.tsx +2 -2
  104. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +54 -0
  105. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +44 -76
  106. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +5 -1
  107. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +7 -1
  108. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +40 -31
  109. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +59 -1
  110. package/src/features/chat/features/workspace/utils/chat-thread-workspace-session.utils.ts +109 -0
  111. package/src/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils.ts +44 -4
  112. package/src/features/chat/index.ts +0 -1
  113. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +51 -99
  114. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +135 -21
  115. package/src/features/chat/managers/chat-session-list.manager.ts +12 -43
  116. package/src/features/chat/managers/chat-thread.manager.ts +108 -34
  117. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +48 -1
  118. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +72 -0
  119. package/src/features/chat/pages/ncp-chat-page.tsx +0 -122
  120. package/src/features/chat/presenters/chat.presenter.ts +2 -19
  121. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +31 -0
  122. package/src/features/chat/stores/chat-thread.store.ts +42 -26
  123. package/src/features/panel-apps/hooks/use-panel-apps.ts +2 -1
  124. package/src/platforms/desktop/components/__tests__/desktop-app-shell.test.tsx +50 -10
  125. package/src/platforms/desktop/components/desktop-app-shell.tsx +34 -10
  126. package/src/platforms/desktop/components/desktop-window-chrome.tsx +41 -16
  127. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +24 -1
  128. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +8 -4
  129. package/src/shared/lib/i18n/locales/en-US/chat.json +20 -2
  130. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  131. package/src/shared/lib/i18n/locales/zh-CN/chat.json +20 -2
  132. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  133. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +49 -0
  134. package/src/shared/lib/ui-document-title/index.ts +50 -21
  135. package/dist/assets/channels-list-page-D6jtziu4.js +0 -8
  136. package/dist/assets/chat-page-BBnNJoKG.js +0 -105
  137. package/dist/assets/doc-browser-C8xOF_9F.js +0 -1
  138. package/dist/assets/doc-browser-DgQedeAn.js +0 -1
  139. package/dist/assets/doc-browser-context-B46tQGmO.js +0 -1
  140. package/dist/assets/index-7o1WdUbU.js +0 -103
  141. package/dist/assets/index-DQWmEWzk.css +0 -1
  142. package/dist/assets/mcp-marketplace-page-CWZN7ohK.js +0 -1
  143. package/dist/assets/mcp-marketplace-page-Dzull_4-.js +0 -40
  144. package/dist/assets/model-config-page-Cv0O3BIj.js +0 -1
  145. package/dist/assets/plus-Bnt56oqG.js +0 -1
  146. package/dist/assets/provider-scoped-model-input-Duuh7i3h.js +0 -1
  147. package/dist/assets/providers-config-page-CPDz0Lxc.js +0 -1
  148. package/dist/assets/remote-B0LMs8u7.js +0 -1
  149. package/dist/assets/rotate-cw-BfyG_KEF.js +0 -1
  150. package/dist/assets/runtime-config-page-q0fvv8ME.js +0 -1
  151. package/dist/assets/use-config-DZkBWIdL.js +0 -1
  152. package/dist/assets/x-CW2KO-Hz.js +0 -1
  153. package/src/features/chat/components/conversation/chat-conversation-alerts.tsx +0 -37
  154. package/src/features/chat/features/input/components/__tests__/chat-attachment-upload-limit.test.ts +0 -38
  155. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +0 -496
  156. package/src/features/chat/features/message/utils/chat-message-inline-content.utils.ts +0 -95
  157. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +0 -448
  158. package/src/features/chat/managers/__tests__/chat-run-snapshot.manager.test.ts +0 -129
  159. package/src/features/chat/managers/__tests__/chat-run.manager.test.ts +0 -236
  160. package/src/features/chat/managers/__tests__/chat-session-preference-sync.manager.test.ts +0 -219
  161. package/src/features/chat/managers/chat-input.manager.ts +0 -430
  162. package/src/features/chat/managers/chat-run.manager.ts +0 -110
  163. package/src/features/chat/managers/chat-session-preference-sync.manager.ts +0 -142
  164. package/src/features/chat/stores/chat-input.store.ts +0 -104
  165. /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
+ }