@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,199 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useState,
6
+ type MutableRefObject,
7
+ } from 'react';
8
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import {
11
+ createChatComposerTextNode,
12
+ type ChatComposerNode,
13
+ } from '@nextclaw/agent-chat-ui';
14
+
15
+ import { I18nProvider } from '@/app/components/i18n-provider';
16
+ import { ChatPresenterProvider, type ChatPresenterLike } from '@/features/chat/components/providers/chat-presenter.provider';
17
+ import {
18
+ SessionConversationInput,
19
+ type SessionConversationInputController,
20
+ } from '@/features/chat/features/conversation/components/session-conversation-input';
21
+ import type {
22
+ SessionConversationInputActions,
23
+ SessionConversationInputPatch,
24
+ SessionConversationInputSnapshot,
25
+ } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
26
+
27
+ vi.mock('@/app/hooks/use-viewport-layout', () => ({
28
+ useViewportLayout: () => ({ isDesktop: true, isMobile: false }),
29
+ }));
30
+
31
+ vi.mock('@/features/panel-apps', () => ({
32
+ usePanelApps: () => ({
33
+ data: { entries: [] },
34
+ isFetching: false,
35
+ isLoading: false,
36
+ }),
37
+ }));
38
+
39
+ vi.mock('@/features/chat/features/input/hooks/use-chat-model-favorites', () => ({
40
+ useChatModelFavorites: () => ({
41
+ favoriteModelValues: [],
42
+ isLoading: false,
43
+ setModelFavorite: vi.fn(),
44
+ }),
45
+ }));
46
+
47
+ vi.mock('@/shared/lib/api', async (importOriginal) => {
48
+ const actual = await importOriginal();
49
+ return {
50
+ ...(actual as object),
51
+ updateNcpSession: vi.fn(),
52
+ };
53
+ });
54
+
55
+ type StreamingInputControl = {
56
+ bumpStream: () => void;
57
+ };
58
+
59
+ const presenter = {
60
+ chatThreadManager: {
61
+ openSideChatDraft: vi.fn(),
62
+ },
63
+ chatUiManager: {
64
+ goToProviders: vi.fn(),
65
+ },
66
+ } as unknown as ChatPresenterLike;
67
+
68
+ const controller: SessionConversationInputController = {
69
+ canStopGeneration: true,
70
+ isSending: true,
71
+ send: vi.fn(),
72
+ sendDisabled: true,
73
+ stop: vi.fn(),
74
+ stopDisabled: false,
75
+ };
76
+
77
+ function createStreamingInputSnapshot(
78
+ nodes: readonly ChatComposerNode[],
79
+ ): SessionConversationInputSnapshot {
80
+ return {
81
+ attachments: [],
82
+ composerFocusRequestId: 0,
83
+ nodes,
84
+ pendingProjectRoot: null,
85
+ pendingSessionType: 'default',
86
+ selectedModel: undefined,
87
+ selectedSessionType: 'default',
88
+ selectedSkills: [],
89
+ selectedThinkingLevel: null,
90
+ sendError: null,
91
+ skillRecords: [],
92
+ text: nodes
93
+ .map((node) => (node.type === 'text' ? node.text : ''))
94
+ .join(''),
95
+ };
96
+ }
97
+
98
+ function StreamingSessionConversationInputHarness({
99
+ controlRef,
100
+ }: {
101
+ controlRef: MutableRefObject<StreamingInputControl | null>;
102
+ }) {
103
+ const [nodes, setNodes] = useState<readonly ChatComposerNode[]>([
104
+ createChatComposerTextNode(''),
105
+ ]);
106
+ const [streamChunk, setStreamChunk] = useState(0);
107
+ const bumpStream = useCallback(() => setStreamChunk((chunk) => chunk + 1), []);
108
+
109
+ useEffect(() => {
110
+ controlRef.current = { bumpStream };
111
+ return () => {
112
+ controlRef.current = null;
113
+ };
114
+ }, [bumpStream, controlRef]);
115
+
116
+ const inputSnapshot = useMemo(() => createStreamingInputSnapshot(nodes), [nodes]);
117
+ const inputActions: SessionConversationInputActions = useMemo(() => ({
118
+ addAttachments: vi.fn(() => []),
119
+ applyPromptSuggestion: vi.fn(),
120
+ consumeComposerFocusRequest: vi.fn(),
121
+ removeAttachment: vi.fn(),
122
+ requestComposerFocusAtEnd: vi.fn(),
123
+ resetComposer: vi.fn(),
124
+ restoreComposer: vi.fn(),
125
+ setAttachments: vi.fn(),
126
+ setPendingProjectRoot: vi.fn(),
127
+ setPendingSessionType: vi.fn(),
128
+ setSelectedModel: vi.fn(),
129
+ setSelectedSkills: vi.fn(),
130
+ setSelectedThinkingLevel: vi.fn(),
131
+ setSendError: vi.fn(),
132
+ syncComposer: vi.fn(),
133
+ update: (patch: SessionConversationInputPatch) => {
134
+ setNodes((currentNodes) => {
135
+ const resolvedPatch = typeof patch === 'function'
136
+ ? patch(createStreamingInputSnapshot(currentNodes))
137
+ : patch;
138
+ return resolvedPatch.nodes ?? currentNodes;
139
+ });
140
+ },
141
+ }), []);
142
+ const inputQuery = useMemo(() => ({
143
+ defaultModel: undefined,
144
+ defaultProjectRoot: null,
145
+ fallbackPreferredModel: undefined,
146
+ fallbackPreferredThinking: undefined,
147
+ isProviderStateResolved: true,
148
+ isSkillsLoading: false,
149
+ modelOptions: [],
150
+ selectedSession: null,
151
+ selectedSessionKey: null,
152
+ sessionTypeState: {
153
+ canEditSessionType: true,
154
+ defaultSessionType: 'default',
155
+ selectedSessionType: 'default',
156
+ selectedSessionTypeOption: null,
157
+ sessionTypeOptions: [],
158
+ sessionTypeUnavailable: false,
159
+ sessionTypeUnavailableMessage: null,
160
+ },
161
+ skillRecords: [],
162
+ }), []);
163
+
164
+ return (
165
+ <I18nProvider>
166
+ <ChatPresenterProvider presenter={presenter}>
167
+ <div data-testid="stream-chunk">{streamChunk}</div>
168
+ <SessionConversationInput
169
+ contextWindow={null}
170
+ controller={controller}
171
+ inputActions={inputActions}
172
+ inputQuery={inputQuery}
173
+ inputSnapshot={inputSnapshot}
174
+ />
175
+ </ChatPresenterProvider>
176
+ </I18nProvider>
177
+ );
178
+ }
179
+
180
+ describe('SessionConversationInput streaming stability', () => {
181
+ it('keeps IME composition stable while simulated streamed output rerenders the owner', async () => {
182
+ const controlRef: MutableRefObject<StreamingInputControl | null> = { current: null };
183
+ render(<StreamingSessionConversationInputHarness controlRef={controlRef} />);
184
+
185
+ const textbox = screen.getByRole('textbox');
186
+ fireEvent.focus(textbox);
187
+ fireEvent.compositionStart(textbox);
188
+
189
+ act(() => {
190
+ controlRef.current?.bumpStream();
191
+ controlRef.current?.bumpStream();
192
+ });
193
+ expect(screen.getByTestId('stream-chunk').textContent).toBe('2');
194
+
195
+ fireEvent.compositionEnd(textbox, { data: '你' });
196
+
197
+ await waitFor(() => expect(textbox.textContent).toBe('你'));
198
+ });
199
+ });
@@ -0,0 +1,308 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import { useLocation } from 'react-router-dom';
3
+
4
+ import { useAppPresenter } from '@/app/components/app-presenter-provider';
5
+ import { ChatConversationContent } from '@/features/chat/components/conversation/chat-conversation-content';
6
+ import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
7
+ import {
8
+ isNcpAgentStartupUnavailableErrorMessage,
9
+ useNcpSessionConversation,
10
+ } from '@/features/chat/features/ncp/hooks/use-ncp-session-conversation';
11
+ import { isNcpChatRuntimeBlocked, resolveNcpChatSendErrorMessage } from '@/features/chat/features/runtime/utils/ncp-chat-runtime-availability.utils';
12
+ import { buildChatContextWindowIndicator } from '@/features/chat/features/session/utils/chat-context-window-indicator.utils';
13
+ import { readNcpContextWindowValue } from '@/features/chat/features/session/utils/ncp-session-context-metadata.utils';
14
+ import { ChatConversationWelcome } from '@/features/chat/features/welcome/components/chat-conversation-welcome';
15
+ import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
16
+ import { useSystemStatus } from '@/features/system-status';
17
+ import { t } from '@/shared/lib/i18n';
18
+
19
+ import {
20
+ useSessionConversationController,
21
+ type SessionConversationMaterializationContext,
22
+ } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
23
+ import { useSessionConversationInputQuery } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-query';
24
+ import { useSessionConversationInputState } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
25
+ import {
26
+ SessionConversationInput,
27
+ type SessionConversationInputController,
28
+ } from './session-conversation-input';
29
+
30
+ type SessionConversationAreaProps = {
31
+ readonly consumeDraftIntent?: boolean;
32
+ readonly materializationContext?: SessionConversationMaterializationContext | null;
33
+ readonly onSessionMaterialized?: (sessionKey: string) => void;
34
+ readonly sessionKey: string | null;
35
+ readonly showWelcomeForDraft?: boolean;
36
+ };
37
+
38
+ function useSessionConversationDraftIntent(params: {
39
+ readonly consumeDraftIntent: boolean;
40
+ readonly applyPromptSuggestion: (prompt: string) => void;
41
+ }) {
42
+ const { applyPromptSuggestion, consumeDraftIntent } = params;
43
+ const appPresenter = useAppPresenter();
44
+ const presenter = usePresenter();
45
+ useEffect(() => {
46
+ if (!consumeDraftIntent) {
47
+ return undefined;
48
+ }
49
+ const applyIntent = (intent: { id: number; prompt: string }) => {
50
+ presenter.chatSessionListManager.createSession();
51
+ presenter.chatSessionListManager.setSelectedAgentId('main');
52
+ applyPromptSuggestion(intent.prompt);
53
+ appPresenter.chatDraftIntentManager.markConsumed(intent.id);
54
+ };
55
+ const unsubscribe = appPresenter.chatDraftIntentManager.subscribe(applyIntent);
56
+ const pendingIntent = appPresenter.chatDraftIntentManager.consumePending();
57
+ if (pendingIntent) {
58
+ applyIntent(pendingIntent);
59
+ }
60
+ return unsubscribe;
61
+ }, [
62
+ appPresenter,
63
+ applyPromptSuggestion,
64
+ consumeDraftIntent,
65
+ presenter,
66
+ ]);
67
+ }
68
+
69
+ type ChatDraftRouteState = {
70
+ readonly sessionType?: unknown;
71
+ readonly projectRoot?: unknown;
72
+ };
73
+
74
+ function readChatDraftRouteState(value: unknown): ChatDraftRouteState | null {
75
+ if (!value || typeof value !== 'object') {
76
+ return null;
77
+ }
78
+ const draft = (value as { chatDraft?: unknown }).chatDraft;
79
+ if (!draft || typeof draft !== 'object') {
80
+ return null;
81
+ }
82
+ return draft as ChatDraftRouteState;
83
+ }
84
+
85
+ function useSessionConversationDraftRouteState(params: {
86
+ readonly sessionKey: string | null;
87
+ readonly setPendingProjectRoot: (projectRoot: string | null) => void;
88
+ readonly setPendingSessionType: (sessionType: string) => void;
89
+ }) {
90
+ const {
91
+ sessionKey,
92
+ setPendingProjectRoot,
93
+ setPendingSessionType,
94
+ } = params;
95
+ const location = useLocation();
96
+ const appliedRouteStateKeyRef = useRef<string | null>(null);
97
+ useEffect(() => {
98
+ if (sessionKey) {
99
+ return;
100
+ }
101
+ const draftState = readChatDraftRouteState(location.state);
102
+ if (!draftState) {
103
+ return;
104
+ }
105
+ const signature = [
106
+ location.key,
107
+ typeof draftState.sessionType === 'string' ? draftState.sessionType : '',
108
+ typeof draftState.projectRoot === 'string' ? draftState.projectRoot : '',
109
+ ].join(':');
110
+ if (appliedRouteStateKeyRef.current === signature) {
111
+ return;
112
+ }
113
+ appliedRouteStateKeyRef.current = signature;
114
+ if (typeof draftState.sessionType === 'string' && draftState.sessionType.trim()) {
115
+ setPendingSessionType(draftState.sessionType);
116
+ }
117
+ setPendingProjectRoot(
118
+ typeof draftState.projectRoot === 'string' && draftState.projectRoot.trim()
119
+ ? draftState.projectRoot
120
+ : null,
121
+ );
122
+ }, [
123
+ location.key,
124
+ location.state,
125
+ sessionKey,
126
+ setPendingProjectRoot,
127
+ setPendingSessionType,
128
+ ]);
129
+ }
130
+
131
+ function SessionConversationAlerts({
132
+ inputQuery,
133
+ }: {
134
+ readonly inputQuery: ReturnType<typeof useSessionConversationInputQuery>;
135
+ }) {
136
+ const presenter = usePresenter();
137
+ const shouldShowProviderHint =
138
+ inputQuery.isProviderStateResolved && inputQuery.modelOptions.length === 0;
139
+ const sessionTypeUnavailableMessage =
140
+ inputQuery.sessionTypeState.sessionTypeUnavailableMessage?.trim() || null;
141
+
142
+ return (
143
+ <>
144
+ {shouldShowProviderHint ? (
145
+ <div className="px-4 py-2.5 border-b border-amber-200/70 bg-amber-50/70 flex items-center justify-between gap-3 shrink-0 sm:px-5">
146
+ <span className="text-xs text-amber-800">
147
+ {t('chatModelNoOptions')}
148
+ </span>
149
+ <button
150
+ type="button"
151
+ onClick={presenter.chatUiManager.goToProviders}
152
+ className="text-xs font-semibold text-amber-900 underline-offset-2 hover:underline"
153
+ >
154
+ {t('chatGoConfigureProvider')}
155
+ </button>
156
+ </div>
157
+ ) : null}
158
+ {inputQuery.sessionTypeState.sessionTypeUnavailable && sessionTypeUnavailableMessage ? (
159
+ <div className="px-4 py-2.5 border-b border-amber-200/70 bg-amber-50/70 shrink-0 sm:px-5">
160
+ <span className="text-xs text-amber-800">
161
+ {sessionTypeUnavailableMessage}
162
+ </span>
163
+ </div>
164
+ ) : null}
165
+ </>
166
+ );
167
+ }
168
+
169
+ export function SessionConversationArea(props: SessionConversationAreaProps) {
170
+ const {
171
+ consumeDraftIntent = false,
172
+ materializationContext = null,
173
+ onSessionMaterialized,
174
+ sessionKey,
175
+ showWelcomeForDraft = true,
176
+ } = props;
177
+ const systemStatus = useSystemStatus();
178
+ const selectedAgentId = useChatSessionListStore(
179
+ (state) => state.snapshot.selectedAgentId,
180
+ );
181
+ const agent = useNcpSessionConversation(sessionKey ?? undefined);
182
+ const {
183
+ inputActions,
184
+ inputSnapshot,
185
+ } = useSessionConversationInputState();
186
+ const inputQuery = useSessionConversationInputQuery({
187
+ sessionKey,
188
+ inputSnapshot,
189
+ setPendingSessionType: inputActions.setPendingSessionType,
190
+ });
191
+ useSessionConversationDraftRouteState({
192
+ sessionKey,
193
+ setPendingProjectRoot: inputActions.setPendingProjectRoot,
194
+ setPendingSessionType: inputActions.setPendingSessionType,
195
+ });
196
+ const isRuntimeBlocked = isNcpChatRuntimeBlocked(systemStatus);
197
+ const currentSessionRunning = agent.isRunning || inputQuery.selectedSession?.status === 'running';
198
+ const rawLastSendError =
199
+ agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
200
+ const filteredLastSendError =
201
+ systemStatus.phase === 'ready' &&
202
+ isNcpAgentStartupUnavailableErrorMessage(rawLastSendError)
203
+ ? null
204
+ : rawLastSendError;
205
+ const lastSendError =
206
+ isRuntimeBlocked
207
+ ? null
208
+ : systemStatus.phase === 'ready'
209
+ ? filteredLastSendError
210
+ : resolveNcpChatSendErrorMessage({
211
+ message: filteredLastSendError,
212
+ status: systemStatus,
213
+ });
214
+ const controllerAgent = useMemo(() => ({
215
+ ...agent,
216
+ isRunning: currentSessionRunning,
217
+ isSending: agent.isSending || currentSessionRunning,
218
+ }), [agent, currentSessionRunning]);
219
+ const controller = useSessionConversationController({
220
+ agent: controllerAgent,
221
+ inputSnapshot,
222
+ inputQuery,
223
+ isRuntimeBlocked,
224
+ materializationContext,
225
+ selectedAgentId,
226
+ sessionKey,
227
+ onSessionMaterialized,
228
+ resetComposer: inputActions.resetComposer,
229
+ restoreComposer: inputActions.restoreComposer,
230
+ setSendError: inputActions.setSendError,
231
+ });
232
+ const controllerRef = useRef(controller);
233
+ useEffect(() => {
234
+ controllerRef.current = controller;
235
+ }, [controller]);
236
+ const inputController = useMemo<SessionConversationInputController>(() => ({
237
+ canStopGeneration: controller.canStopGeneration,
238
+ isSending: controller.isSending,
239
+ send: () => controllerRef.current.send(),
240
+ sendDisabled: controller.sendDisabled,
241
+ stop: () => controllerRef.current.stop(),
242
+ stopDisabled: controller.stopDisabled,
243
+ }), [
244
+ controller.canStopGeneration,
245
+ controller.isSending,
246
+ controller.sendDisabled,
247
+ controller.stopDisabled,
248
+ ]);
249
+ const contextWindow = useMemo(
250
+ () => buildChatContextWindowIndicator(readNcpContextWindowValue(agent.snapshot.contextWindow)),
251
+ [agent.snapshot.contextWindow],
252
+ );
253
+ const displayInputSnapshot = useMemo(() => ({
254
+ ...inputSnapshot,
255
+ sendError: lastSendError ?? inputSnapshot.sendError,
256
+ }), [inputSnapshot, lastSendError]);
257
+ useSessionConversationDraftIntent({
258
+ consumeDraftIntent,
259
+ applyPromptSuggestion: inputActions.applyPromptSuggestion,
260
+ });
261
+ const renderInput = useCallback((surface: 'default' | 'embedded') => (
262
+ <SessionConversationInput
263
+ contextWindow={contextWindow}
264
+ controller={inputController}
265
+ inputActions={inputActions}
266
+ inputQuery={inputQuery}
267
+ inputSnapshot={displayInputSnapshot}
268
+ surface={surface}
269
+ />
270
+ ), [
271
+ contextWindow,
272
+ displayInputSnapshot,
273
+ inputController,
274
+ inputActions,
275
+ inputQuery,
276
+ ]);
277
+ const showWelcome =
278
+ showWelcomeForDraft &&
279
+ !sessionKey &&
280
+ agent.visibleMessages.length === 0 &&
281
+ !agent.isHydrating;
282
+
283
+ return (
284
+ <>
285
+ <SessionConversationAlerts inputQuery={inputQuery} />
286
+ <ChatConversationContent
287
+ isAwaitingAssistantOutput={controller.isSending && currentSessionRunning}
288
+ isHistoryLoading={agent.isHydrating}
289
+ isSending={controller.isSending}
290
+ messages={agent.visibleMessages}
291
+ sessionKey={sessionKey}
292
+ showWelcome={showWelcome}
293
+ welcomeSlot={
294
+ <ChatConversationWelcome
295
+ inputSlot={renderInput('embedded')}
296
+ pendingProjectRoot={inputSnapshot.pendingProjectRoot}
297
+ pendingSessionType={inputSnapshot.pendingSessionType}
298
+ selectedSessionTypeValue={inputSnapshot.selectedSessionType}
299
+ onSelectProjectRoot={inputActions.setPendingProjectRoot}
300
+ onSelectPrompt={inputActions.applyPromptSuggestion}
301
+ onSelectSessionType={inputActions.setPendingSessionType}
302
+ />
303
+ }
304
+ />
305
+ {showWelcome ? null : renderInput('default')}
306
+ </>
307
+ );
308
+ }