@nextclaw/ui 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/assets/api-DLtsiBKm.js +13 -0
  3. package/dist/assets/appearance-settings-page-CtkeYXux.js +1 -0
  4. package/dist/assets/book-open-BQwWBDKb.js +1 -0
  5. package/dist/assets/channels-list-page-Cv8o9Hxg.js +8 -0
  6. package/dist/assets/chat-page-BtA7H1Ea.js +1 -0
  7. package/dist/assets/{config-split-page-CRhlZ_pH.js → config-split-page-EidRBpP2.js} +1 -1
  8. package/dist/assets/confirm-dialog-iIJFFp2T.js +5 -0
  9. package/dist/assets/desktop-update-config-C6n_TB1r.js +1 -0
  10. package/dist/assets/{dist-CikkhOlq.js → dist-CBxWOazz.js} +1 -1
  11. package/dist/assets/{dist-PVA_xL1D.js → dist-D2eZC3OR.js} +1 -1
  12. package/dist/assets/doc-browser-B76XOXmW.js +1 -0
  13. package/dist/assets/doc-browser-DBnj9UMB.js +1 -0
  14. package/dist/assets/{doc-browser-context-MTw4EvKd.js → doc-browser-context-Sv-044Cl.js} +1 -1
  15. package/dist/assets/{ellipsis-y6vDtq4M.js → ellipsis-2uzhypw5.js} +1 -1
  16. package/dist/assets/es2015-DhQo_oS6.js +41 -0
  17. package/dist/assets/{external-link-DVJIQf_G.js → external-link-CM5dnUI6.js} +1 -1
  18. package/dist/assets/host-capabilities-9bB_998Z.js +1 -0
  19. package/dist/assets/i18n-provider-di1waTGX.js +1 -0
  20. package/dist/assets/index-CBRP-7-a.css +1 -0
  21. package/dist/assets/index-Z1fYpbNd.js +104 -0
  22. package/dist/assets/loader-circle-BjmN5DeJ.js +1 -0
  23. package/dist/assets/mcp-marketplace-page-C_MVQUbT.js +9 -0
  24. package/dist/assets/mcp-marketplace-page-CoZIiD6G.js +1 -0
  25. package/dist/assets/model-config-page-Cdr9P6TQ.js +1 -0
  26. package/dist/assets/plus-3ztRZD_l.js +1 -0
  27. package/dist/assets/provider-scoped-model-input-g1WkGiJ8.js +1 -0
  28. package/dist/assets/providers-config-page-CWlO7J4A.js +1 -0
  29. package/dist/assets/{react-VNqT5Zp7.js → react-DjpzoZXB.js} +1 -1
  30. package/dist/assets/refresh-cw-DaTv3zxE.js +1 -0
  31. package/dist/assets/remote-CKWZfVVB.js +1 -0
  32. package/dist/assets/rotate-cw-CHcaRUDz.js +1 -0
  33. package/dist/assets/runtime-config-page-CfTqh1nv.js +1 -0
  34. package/dist/assets/{save-BOJ477H-.js → save-BZof1uie.js} +1 -1
  35. package/dist/assets/{search-BEti5beA.js → search-MxAarS_g.js} +1 -1
  36. package/dist/assets/search-config-page-Dm8daYny.js +1 -0
  37. package/dist/assets/{secrets-config-page--IzrIOid.js → secrets-config-page-D8AbcR05.js} +2 -2
  38. package/dist/assets/security-config-Ds0LsJmo.js +1 -0
  39. package/dist/assets/select-D7GcNP0z.js +1 -0
  40. package/dist/assets/setting-row-CuozlnJd.js +1 -0
  41. package/dist/assets/side-dock-BcLau5S_.js +3 -0
  42. package/dist/assets/skeleton-shfmXuYp.js +1 -0
  43. package/dist/assets/switch-C1BWMU5c.js +1 -0
  44. package/dist/assets/tag-chip-DCUrQUCP.js +1 -0
  45. package/dist/assets/tooltip-DfAHHA76.js +1 -0
  46. package/dist/assets/use-config-DAdbT5QJ.js +1 -0
  47. package/dist/assets/use-confirm-dialog-eWcJ6V-s.js +1 -0
  48. package/dist/assets/x-DqL1mYLC.js +1 -0
  49. package/dist/index.html +53 -17
  50. package/index.html +25 -2
  51. package/package.json +9 -9
  52. package/src/app/components/layout/__tests__/app-layout.test.tsx +27 -0
  53. package/src/app/components/layout/__tests__/settings-entry-page.test.tsx +9 -1
  54. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +6 -3
  55. package/src/app/components/layout/app-layout.tsx +10 -3
  56. package/src/app/components/layout/sidebar-items.tsx +8 -8
  57. package/src/app/components/layout/sidebar-rail.styles.ts +4 -2
  58. package/src/app/components/layout/sidebar.tsx +21 -28
  59. package/src/app/configs/app-navigation.config.ts +11 -5
  60. package/src/app/index.tsx +8 -0
  61. package/src/app/managers/__tests__/viewport-layout.manager.test.ts +59 -0
  62. package/src/app/managers/viewport-layout.manager.ts +39 -0
  63. package/src/app/presenters/app.presenter.ts +25 -2
  64. package/src/app/styles/design-system.css +91 -4
  65. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +86 -0
  66. package/src/features/chat/components/conversation/chat-conversation-content.tsx +47 -17
  67. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +7 -0
  68. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +9 -5
  69. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +5 -5
  70. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +51 -2
  71. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +170 -1
  72. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +23 -1
  73. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +24 -7
  74. package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +96 -0
  75. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +181 -0
  76. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +351 -89
  77. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +29 -4
  78. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +28 -3
  79. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -8
  80. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +50 -14
  81. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +77 -6
  82. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +21 -12
  83. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +166 -0
  84. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +19 -11
  85. package/src/features/chat/features/message/components/chat-message-list.container.tsx +38 -64
  86. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +59 -0
  87. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +56 -1
  88. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -2
  89. package/src/features/chat/features/message/utils/chat-message-process-summary.utils.ts +80 -0
  90. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +12 -3
  91. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +60 -0
  92. package/src/features/chat/features/message/utils/chat-message.utils.ts +3 -0
  93. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +39 -0
  94. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
  95. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +1 -1
  96. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +3 -1
  97. package/src/features/chat/features/session/components/session-header/chat-session-metadata-dialog.tsx +1 -1
  98. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +270 -115
  99. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumb-browser.tsx +121 -0
  100. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +75 -20
  101. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +70 -5
  102. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils.ts +83 -0
  103. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +79 -1
  104. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +2 -0
  105. package/src/features/chat/managers/chat-thread.manager.ts +9 -3
  106. package/src/features/chat/managers/chat-ui.manager.ts +5 -1
  107. package/src/features/chat/presenters/chat.presenter.ts +2 -1
  108. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +4 -0
  109. package/src/features/chat/stores/chat-thread.store.ts +6 -64
  110. package/src/features/chat/types/chat-message.types.ts +4 -0
  111. package/src/features/marketplace/components/__tests__/marketplace-page-detail.test.tsx +33 -26
  112. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +1 -0
  113. package/src/features/marketplace/components/curated-shelves/__tests__/marketplace-curated-scene-route.test.tsx +1 -0
  114. package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.tsx +115 -0
  115. package/src/features/marketplace/components/marketplace-detail-doc.tsx +441 -0
  116. package/src/features/marketplace/components/mcp/__tests__/mcp-marketplace-page.test.tsx +3 -3
  117. package/src/features/marketplace/components/mcp/mcp-marketplace-doc.ts +0 -60
  118. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +64 -29
  119. package/src/features/marketplace/hooks/use-marketplace-item-detail.ts +42 -31
  120. package/src/features/marketplace/index.ts +3 -0
  121. package/src/features/marketplace/stores/marketplace-detail-doc.store.ts +38 -0
  122. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +32 -2
  123. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -0
  124. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +8 -3
  125. package/src/features/settings/pages/__tests__/appearance-settings-page.test.tsx +25 -0
  126. package/src/features/settings/pages/appearance-settings-page.tsx +40 -0
  127. package/src/features/side-dock/components/__tests__/side-dock.test.tsx +44 -2
  128. package/src/features/side-dock/components/side-dock.tsx +100 -13
  129. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +10 -0
  130. package/src/features/side-dock/managers/__tests__/side-dock.manager.test.ts +32 -2
  131. package/src/features/side-dock/managers/side-dock.manager.ts +17 -7
  132. package/src/features/side-dock/stores/side-dock.store.ts +9 -0
  133. package/src/features/side-dock/types/side-dock.types.ts +10 -3
  134. package/src/features/side-dock/utils/side-dock-item.utils.ts +15 -2
  135. package/src/index.css +501 -27
  136. package/src/platforms/desktop/components/desktop-window-chrome.tsx +1 -1
  137. package/src/shared/components/doc-browser/__tests__/doc-browser-context.test.tsx +30 -1
  138. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +206 -38
  139. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +34 -11
  140. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +1 -1
  141. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +23 -5
  142. package/src/shared/components/doc-browser/doc-browser.tsx +128 -75
  143. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +9 -1
  144. package/src/shared/components/doc-browser/utils/doc-browser-floating-panel.utils.ts +83 -0
  145. package/src/shared/components/ui/__tests__/dialog.test.tsx +27 -0
  146. package/src/shared/components/ui/actions/icon-action-button.tsx +14 -2
  147. package/src/shared/components/ui/dialog.tsx +2 -2
  148. package/src/shared/components/ui/popover.tsx +1 -1
  149. package/src/shared/components/ui/select.tsx +1 -1
  150. package/src/shared/components/ui/switch.tsx +4 -3
  151. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +54 -0
  152. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +4 -5
  153. package/src/shared/components/ui/tooltip.tsx +1 -1
  154. package/src/shared/lib/api/utils/server-path.utils.ts +27 -1
  155. package/src/shared/lib/i18n/locales/en-US/chat.json +15 -6
  156. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  157. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +11 -0
  158. package/src/shared/lib/i18n/locales/en-US/marketplace.json +5 -0
  159. package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -6
  160. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  161. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +11 -0
  162. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +5 -0
  163. package/src/shared/lib/session-project/__tests__/workspace-file-breadcrumb.test.ts +14 -0
  164. package/src/shared/lib/session-project/index.ts +1 -1
  165. package/src/shared/lib/session-project/{workspace-file-breadcrumb.ts → workspace-file-breadcrumb.utils.ts} +62 -8
  166. package/src/shared/lib/theme/index.ts +51 -13
  167. package/dist/assets/api-BP_8IaxB.js +0 -13
  168. package/dist/assets/channels-list-page-DEh5Jjqx.js +0 -8
  169. package/dist/assets/chat-page-BRB0N5WW.js +0 -105
  170. package/dist/assets/confirm-dialog-DsivFJ4r.js +0 -5
  171. package/dist/assets/desktop-update-config-B0w9p2ep.js +0 -1
  172. package/dist/assets/doc-browser-BoNJ7-tE.js +0 -1
  173. package/dist/assets/doc-browser-CdJ_d4ik.js +0 -1
  174. package/dist/assets/index-BiE-K8-q.css +0 -1
  175. package/dist/assets/index-D9BAE_L_.js +0 -105
  176. package/dist/assets/mcp-marketplace-page-B1SZNY5m.js +0 -40
  177. package/dist/assets/mcp-marketplace-page-Cq_Tw42T.js +0 -1
  178. package/dist/assets/model-config-page-DotaTUHb.js +0 -1
  179. package/dist/assets/plus-BbZqquJe.js +0 -1
  180. package/dist/assets/provider-scoped-model-input-DsFRuWJH.js +0 -1
  181. package/dist/assets/providers-config-page-DW4tQSLN.js +0 -1
  182. package/dist/assets/remote-B8TiUfwW.js +0 -1
  183. package/dist/assets/rotate-cw-DjHDTkCv.js +0 -1
  184. package/dist/assets/runtime-config-page-S6ErRHR8.js +0 -1
  185. package/dist/assets/search-config-page-DGj5am03.js +0 -1
  186. package/dist/assets/select-hoj8dK4K.js +0 -41
  187. package/dist/assets/tag-chip-T8-h-die.js +0 -1
  188. package/dist/assets/use-config-CjRV2hwL.js +0 -1
  189. package/dist/assets/x-D9PXs_xP.js +0 -1
  190. package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.ts +0 -40
  191. package/src/features/marketplace/components/detail-doc/marketplace-detail-doc-renderer.ts +0 -201
  192. package/src/features/marketplace/components/marketplace-detail-doc.ts +0 -142
  193. /package/dist/assets/{config-hints-BkYBc7z0.js → config-hints-fGnUjDe9.js} +0 -0
  194. /package/dist/assets/{middleware-CkFT3R4v.js → middleware-BILWbJE4.js} +0 -0
@@ -11,6 +11,7 @@ import {
11
11
  createChatComposerTextNode,
12
12
  type ChatComposerNode,
13
13
  } from '@nextclaw/agent-chat-ui';
14
+ import type { NcpAgentSendEnvelope, NcpRunHandle } from '@nextclaw/ncp';
14
15
 
15
16
  import { I18nProvider } from '@/app/components/i18n-provider';
16
17
  import { ChatPresenterProvider, type ChatPresenterLike } from '@/features/chat/components/providers/chat-presenter.provider';
@@ -18,11 +19,15 @@ import {
18
19
  SessionConversationInput,
19
20
  type SessionConversationInputController,
20
21
  } from '@/features/chat/features/conversation/components/session-conversation-input';
22
+ import { useSessionConversationController } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
21
23
  import type {
22
24
  SessionConversationInputActions,
23
25
  SessionConversationInputPatch,
24
26
  SessionConversationInputSnapshot,
25
27
  } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
28
+ import { useSessionConversationInputState } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
29
+
30
+ const uploadNcpAssetsMock = vi.hoisted(() => vi.fn());
26
31
 
27
32
  vi.mock('@/app/hooks/use-viewport-layout', () => ({
28
33
  useViewportLayout: () => ({ isDesktop: true, isMobile: false }),
@@ -49,6 +54,7 @@ vi.mock('@/shared/lib/api', async (importOriginal) => {
49
54
  return {
50
55
  ...(actual as object),
51
56
  updateNcpSession: vi.fn(),
57
+ uploadNcpAssets: uploadNcpAssetsMock,
52
58
  };
53
59
  });
54
60
 
@@ -62,12 +68,16 @@ const presenter = {
62
68
  },
63
69
  chatUiManager: {
64
70
  goToProviders: vi.fn(),
71
+ showContent: vi.fn(),
65
72
  },
66
73
  } as unknown as ChatPresenterLike;
67
74
 
68
75
  const controller: SessionConversationInputController = {
69
76
  canStopGeneration: true,
77
+ deleteQueuedInput: vi.fn(),
78
+ editQueuedInput: vi.fn(),
70
79
  isSending: true,
80
+ queuedInputs: [],
71
81
  send: vi.fn(),
72
82
  sendDisabled: true,
73
83
  stop: vi.fn(),
@@ -96,8 +106,10 @@ function createStreamingInputSnapshot(
96
106
  }
97
107
 
98
108
  function StreamingSessionConversationInputHarness({
109
+ controllerOverride = controller,
99
110
  controlRef,
100
111
  }: {
112
+ controllerOverride?: SessionConversationInputController;
101
113
  controlRef: MutableRefObject<StreamingInputControl | null>;
102
114
  }) {
103
115
  const [nodes, setNodes] = useState<readonly ChatComposerNode[]>([
@@ -167,7 +179,7 @@ function StreamingSessionConversationInputHarness({
167
179
  <div data-testid="stream-chunk">{streamChunk}</div>
168
180
  <SessionConversationInput
169
181
  contextWindow={null}
170
- controller={controller}
182
+ controller={controllerOverride}
171
183
  inputActions={inputActions}
172
184
  inputQuery={inputQuery}
173
185
  inputSnapshot={inputSnapshot}
@@ -196,4 +208,161 @@ describe('SessionConversationInput streaming stability', () => {
196
208
 
197
209
  await waitFor(() => expect(textbox.textContent).toBe('你'));
198
210
  });
211
+
212
+ it('renders queued inputs above the composer with edit and delete actions', () => {
213
+ const controlRef: MutableRefObject<StreamingInputControl | null> = { current: null };
214
+ const deleteQueuedInput = vi.fn();
215
+ const editQueuedInput = vi.fn();
216
+ render(
217
+ <StreamingSessionConversationInputHarness
218
+ controlRef={controlRef}
219
+ controllerOverride={{
220
+ ...controller,
221
+ deleteQueuedInput,
222
+ editQueuedInput,
223
+ queuedInputs: [
224
+ { id: 'queued-1', preview: '先做 A' },
225
+ { id: 'queued-2', preview: '再做 B' },
226
+ ],
227
+ }}
228
+ />,
229
+ );
230
+
231
+ expect(screen.getByText('先做 A')).toBeTruthy();
232
+ expect(screen.getByText('再做 B')).toBeTruthy();
233
+ const inputShell = document.querySelector('.nextclaw-chat-input-bar-shell');
234
+ expect(inputShell?.contains(screen.getByText('先做 A'))).toBe(true);
235
+ expect(inputShell?.contains(screen.getByText('再做 B'))).toBe(true);
236
+
237
+ fireEvent.click(screen.getAllByRole('button', { name: 'Edit queued input' })[0]);
238
+ fireEvent.click(screen.getAllByRole('button', { name: 'Delete queued input' })[1]);
239
+
240
+ expect(editQueuedInput).toHaveBeenCalledWith('queued-1');
241
+ expect(deleteQueuedInput).toHaveBeenCalledWith('queued-2');
242
+ });
243
+ });
244
+
245
+ type AttachmentSubmitAgentSend = (envelope: NcpAgentSendEnvelope) => Promise<NcpRunHandle | null>;
246
+
247
+ function createAttachmentRunHandle(): NcpRunHandle {
248
+ return {
249
+ assistantMessageId: null,
250
+ correlationId: undefined,
251
+ runId: 'run-attachment',
252
+ sessionId: 'session-attachment',
253
+ userMessageId: 'user-message-attachment',
254
+ };
255
+ }
256
+
257
+ function AttachmentSubmitHarness({ send }: { readonly send: AttachmentSubmitAgentSend }) {
258
+ const { inputActions, inputSnapshot } = useSessionConversationInputState();
259
+ const inputQuery = useMemo(() => ({
260
+ defaultModel: 'test-model',
261
+ defaultProjectRoot: null,
262
+ fallbackPreferredModel: undefined,
263
+ fallbackPreferredThinking: undefined,
264
+ isProviderStateResolved: true,
265
+ isSkillsLoading: false,
266
+ modelOptions: [
267
+ {
268
+ value: 'test-model',
269
+ modelLabel: 'Test Model',
270
+ providerLabel: 'Test',
271
+ thinkingCapability: null,
272
+ },
273
+ ],
274
+ selectedSession: null,
275
+ selectedSessionKey: 'session-attachment',
276
+ sessionTypeState: {
277
+ canEditSessionType: true,
278
+ defaultSessionType: 'default',
279
+ selectedSessionType: 'default',
280
+ selectedSessionTypeOption: null,
281
+ sessionTypeOptions: [],
282
+ sessionTypeUnavailable: false,
283
+ sessionTypeUnavailableMessage: null,
284
+ },
285
+ skillRecords: [],
286
+ }), []);
287
+ const agent = useMemo(() => ({
288
+ abort: vi.fn(),
289
+ isHydrating: false,
290
+ isRunning: false,
291
+ isSending: false,
292
+ send,
293
+ snapshot: {
294
+ activeRun: null,
295
+ },
296
+ visibleMessages: [],
297
+ }), [send]);
298
+ const controller = useSessionConversationController({
299
+ agent,
300
+ inputSnapshot,
301
+ inputQuery,
302
+ isRuntimeBlocked: false,
303
+ selectedAgentId: 'main',
304
+ sessionKey: 'session-attachment',
305
+ resetComposer: inputActions.resetComposer,
306
+ restoreComposer: inputActions.restoreComposer,
307
+ setSendError: inputActions.setSendError,
308
+ });
309
+
310
+ return (
311
+ <I18nProvider>
312
+ <ChatPresenterProvider presenter={presenter}>
313
+ <SessionConversationInput
314
+ contextWindow={null}
315
+ controller={controller}
316
+ inputActions={inputActions}
317
+ inputQuery={inputQuery}
318
+ inputSnapshot={inputSnapshot}
319
+ />
320
+ </ChatPresenterProvider>
321
+ </I18nProvider>
322
+ );
323
+ }
324
+
325
+ describe('SessionConversationInput attachment submit', () => {
326
+ it('keeps uploaded file attachments in the outgoing send envelope after token insertion', async () => {
327
+ uploadNcpAssetsMock.mockResolvedValueOnce([
328
+ {
329
+ id: 'uploaded-image',
330
+ name: 'sample.png',
331
+ mimeType: 'image/png',
332
+ sizeBytes: 11,
333
+ assetUri: 'asset://store/sample.png',
334
+ url: '/api/ncp/assets/content?uri=asset%3A%2F%2Fstore%2Fsample.png',
335
+ },
336
+ ]);
337
+ const send = vi.fn<AttachmentSubmitAgentSend>(async () => createAttachmentRunHandle());
338
+
339
+ render(<AttachmentSubmitHarness send={send} />);
340
+
341
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement | null;
342
+ expect(fileInput).toBeTruthy();
343
+ await act(async () => {
344
+ fireEvent.change(fileInput!, {
345
+ target: {
346
+ files: [new File(['image-bytes'], 'sample.png', { type: 'image/png' })],
347
+ },
348
+ });
349
+ await Promise.resolve();
350
+ });
351
+
352
+ await waitFor(() => expect(screen.getByText('sample.png')).toBeTruthy());
353
+
354
+ fireEvent.click(screen.getByRole('button', { name: /Send|发送/ }));
355
+
356
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
357
+ expect(send.mock.calls[0]?.[0].message.parts).toEqual([
358
+ {
359
+ type: 'file',
360
+ name: 'sample.png',
361
+ mimeType: 'image/png',
362
+ assetUri: 'asset://store/sample.png',
363
+ url: '/api/ncp/assets/content?uri=asset%3A%2F%2Fstore%2Fsample.png',
364
+ sizeBytes: 11,
365
+ },
366
+ ]);
367
+ });
199
368
  });
@@ -214,7 +214,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
214
214
  const controllerAgent = useMemo(() => ({
215
215
  ...agent,
216
216
  isRunning: currentSessionRunning,
217
- isSending: agent.isSending || currentSessionRunning,
217
+ isSending: agent.isSending,
218
218
  }), [agent, currentSessionRunning]);
219
219
  const controller = useSessionConversationController({
220
220
  agent: controllerAgent,
@@ -235,7 +235,10 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
235
235
  }, [controller]);
236
236
  const inputController = useMemo<SessionConversationInputController>(() => ({
237
237
  canStopGeneration: controller.canStopGeneration,
238
+ deleteQueuedInput: (id: string) => controllerRef.current.deleteQueuedInput(id),
239
+ editQueuedInput: (id: string) => controllerRef.current.editQueuedInput(id),
238
240
  isSending: controller.isSending,
241
+ queuedInputs: controller.queuedInputs,
239
242
  send: () => controllerRef.current.send(),
240
243
  sendDisabled: controller.sendDisabled,
241
244
  stop: () => controllerRef.current.stop(),
@@ -243,6 +246,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
243
246
  }), [
244
247
  controller.canStopGeneration,
245
248
  controller.isSending,
249
+ controller.queuedInputs,
246
250
  controller.sendDisabled,
247
251
  controller.stopDisabled,
248
252
  ]);
@@ -254,6 +258,23 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
254
258
  ...inputSnapshot,
255
259
  sendError: lastSendError ?? inputSnapshot.sendError,
256
260
  }), [inputSnapshot, lastSendError]);
261
+ const sessionFailurePreview = inputQuery.selectedSession?.activityPreview;
262
+ const sessionFailureMessage =
263
+ sessionFailurePreview?.state === 'failed'
264
+ ? sessionFailurePreview.statusText?.trim() ||
265
+ sessionFailurePreview.replyText?.trim() ||
266
+ null
267
+ : null;
268
+ const sessionFailureSlot = sessionFailureMessage ? (
269
+ <div className="rounded-lg border border-red-200/80 bg-red-50/80 px-3 py-2.5 shadow-sm">
270
+ <div className="text-xs font-semibold text-red-800">
271
+ {t('chatSessionErrorTitle')}
272
+ </div>
273
+ <div className="mt-1 whitespace-pre-wrap break-words text-xs leading-5 text-red-700">
274
+ {sessionFailureMessage}
275
+ </div>
276
+ </div>
277
+ ) : null;
257
278
  useSessionConversationDraftIntent({
258
279
  consumeDraftIntent,
259
280
  applyPromptSuggestion: inputActions.applyPromptSuggestion,
@@ -287,6 +308,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
287
308
  isAwaitingAssistantOutput={controller.isSending && currentSessionRunning}
288
309
  isHistoryLoading={agent.isHydrating}
289
310
  isSending={controller.isSending}
311
+ bottomSlot={sessionFailureSlot}
290
312
  messages={agent.visibleMessages}
291
313
  sessionKey={sessionKey}
292
314
  showWelcome={showWelcome}
@@ -50,6 +50,9 @@ import {
50
50
 
51
51
  import { useSessionConversationInputAttachments } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-attachments';
52
52
  import type { useSessionConversationInputQuery } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-query';
53
+ import type {
54
+ SessionConversationQueuedInput,
55
+ } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
53
56
  import type {
54
57
  SessionConversationInputActions,
55
58
  SessionConversationInputSnapshot,
@@ -59,12 +62,16 @@ import {
59
62
  buildSessionConversationToolbarSelects,
60
63
  resolveThinkingForConversationModel,
61
64
  } from '@/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils';
65
+ import { SessionQueuedInputRows } from './session-queued-input-rows';
62
66
 
63
67
  type SessionConversationInputQuery = ReturnType<typeof useSessionConversationInputQuery>;
64
68
 
65
69
  export type SessionConversationInputController = {
66
70
  readonly canStopGeneration: boolean;
71
+ readonly deleteQueuedInput: (id: string) => void;
72
+ readonly editQueuedInput: (id: string) => void;
67
73
  readonly isSending: boolean;
74
+ readonly queuedInputs: readonly SessionConversationQueuedInput[];
68
75
  readonly sendDisabled: boolean;
69
76
  readonly stopDisabled: boolean;
70
77
  readonly send: () => Promise<void> | void;
@@ -215,6 +222,13 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
215
222
  language,
216
223
  presenter.chatThreadManager,
217
224
  ]);
225
+ const handleSlashPanelAppSelect = useCallback(
226
+ (appId: string) =>
227
+ void presenter.chatUiManager.showContent({
228
+ target: { type: 'panel_app', payload: { appId } },
229
+ }),
230
+ [presenter.chatUiManager],
231
+ );
218
232
  const { inputSurfaceState, setInputSurfaceTrigger } = useChatInputSurfaceState({
219
233
  commands: slashCommands,
220
234
  isSkillsLoading: inputQuery.isSkillsLoading,
@@ -222,6 +236,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
222
236
  slashTexts: labels.slashTexts,
223
237
  },
224
238
  language,
239
+ onSelectPanelApp: handleSlashPanelAppSelect,
225
240
  onSelectSkill: chatRecentSkillsManager.remember,
226
241
  recentSkillValues,
227
242
  skillRecords,
@@ -275,15 +290,14 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
275
290
  }, [inputQuery.selectedSession, inputQuery.selectedSessionKey]);
276
291
  const handleNodesChange = useCallback((nodes: SessionConversationInputSnapshot['nodes']) => {
277
292
  const nextNodes = [...nodes];
278
- const attachments = pruneComposerAttachments(nextNodes, inputSnapshot.attachments);
279
- inputActions.update({
293
+ inputActions.update((current) => ({
280
294
  nodes: nextNodes,
281
- attachments,
295
+ attachments: pruneComposerAttachments(nextNodes, current.attachments),
282
296
  text: deriveChatComposerDraft(nextNodes),
283
297
  selectedSkills: deriveSelectedSkillsFromComposer(nextNodes),
284
298
  sendError: null,
285
- });
286
- }, [inputActions, inputSnapshot.attachments]);
299
+ }));
300
+ }, [inputActions]);
287
301
  const handleModelChange = useCallback((value: string) => {
288
302
  const nextThinkingLevel = resolveThinkingForConversationModel(
289
303
  modelRecords.find((option) => option.value === value),
@@ -380,6 +394,9 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
380
394
  <ChatInputBar
381
395
  ref={inputBarRef}
382
396
  surface={surface}
397
+ topSlot={controller.queuedInputs.length > 0
398
+ ? <SessionQueuedInputRows controller={controller} />
399
+ : null}
383
400
  composer={{
384
401
  nodes: [...inputSnapshot.nodes],
385
402
  placeholder: textareaPlaceholder,
@@ -407,7 +424,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
407
424
  label: t('chatInputAttach'),
408
425
  icon: 'paperclip' as const,
409
426
  iconOnly: true,
410
- disabled: !attachmentSupported || inputDisabled || controller.isSending,
427
+ disabled: !attachmentSupported || inputDisabled,
411
428
  onClick: () => fileInputRef.current?.click(),
412
429
  },
413
430
  ],
@@ -419,7 +436,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
419
436
  sendDisabled: controller.sendDisabled,
420
437
  stopDisabled: controller.stopDisabled,
421
438
  stopHint: t('chatStopUnavailable'),
422
- sendButtonLabel: t('chatSend'),
439
+ sendButtonLabel: controller.isSending ? t('chatQueueSend') : t('chatSend'),
423
440
  stopButtonLabel: t('chatStop'),
424
441
  contextWindow,
425
442
  onSend: controller.send,
@@ -0,0 +1,96 @@
1
+ import type { ReactNode } from 'react';
2
+ import { CornerDownRight, Pencil, Trash2 } from 'lucide-react';
3
+
4
+ import {
5
+ Tooltip,
6
+ TooltipContent,
7
+ TooltipProvider,
8
+ TooltipTrigger,
9
+ } from '@/shared/components/ui/tooltip';
10
+ import { t } from '@/shared/lib/i18n';
11
+ import type {
12
+ SessionConversationQueuedInput,
13
+ } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
14
+
15
+ type SessionQueuedInputRowsController = {
16
+ readonly deleteQueuedInput: (id: string) => void;
17
+ readonly editQueuedInput: (id: string) => void;
18
+ readonly queuedInputs: readonly SessionConversationQueuedInput[];
19
+ };
20
+
21
+ type SessionQueuedInputRowsProps = {
22
+ readonly controller: SessionQueuedInputRowsController;
23
+ };
24
+
25
+ type QueuedInputIconButtonProps = {
26
+ readonly children: ReactNode;
27
+ readonly disabled?: boolean;
28
+ readonly label: string;
29
+ readonly onClick: () => void;
30
+ };
31
+
32
+ function QueuedInputIconButton({
33
+ children,
34
+ disabled = false,
35
+ label,
36
+ onClick,
37
+ }: QueuedInputIconButtonProps) {
38
+ return (
39
+ <Tooltip>
40
+ <TooltipTrigger asChild>
41
+ <span className="inline-flex">
42
+ <button
43
+ type="button"
44
+ aria-label={label}
45
+ disabled={disabled}
46
+ onClick={onClick}
47
+ className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:text-muted-foreground/50"
48
+ >
49
+ {children}
50
+ </button>
51
+ </span>
52
+ </TooltipTrigger>
53
+ <TooltipContent side="top">{label}</TooltipContent>
54
+ </Tooltip>
55
+ );
56
+ }
57
+
58
+ export function SessionQueuedInputRows({
59
+ controller,
60
+ }: SessionQueuedInputRowsProps) {
61
+ if (controller.queuedInputs.length === 0) {
62
+ return null;
63
+ }
64
+
65
+ return (
66
+ <TooltipProvider delayDuration={120}>
67
+ <div className="flex flex-col gap-0.5">
68
+ {controller.queuedInputs.map((item) => (
69
+ <div
70
+ key={item.id}
71
+ className="flex min-h-8 min-w-0 items-center gap-2 text-sm"
72
+ >
73
+ <CornerDownRight className="h-4 w-4 shrink-0 text-muted-foreground/70" />
74
+ <span className="min-w-0 flex-1 truncate font-medium text-foreground/80">
75
+ {item.preview || t('chatQueuedBannerAttachmentFallback')}
76
+ </span>
77
+ <div className="flex shrink-0 items-center gap-1.5">
78
+ <QueuedInputIconButton
79
+ label={t('chatQueuedEdit')}
80
+ onClick={() => controller.editQueuedInput(item.id)}
81
+ >
82
+ <Pencil className="h-4 w-4" />
83
+ </QueuedInputIconButton>
84
+ <QueuedInputIconButton
85
+ label={t('chatQueuedDelete')}
86
+ onClick={() => controller.deleteQueuedInput(item.id)}
87
+ >
88
+ <Trash2 className="h-4 w-4" />
89
+ </QueuedInputIconButton>
90
+ </div>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ </TooltipProvider>
95
+ );
96
+ }
@@ -0,0 +1,181 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import type { NcpAgentSendEnvelope, NcpRunHandle } from '@nextclaw/ncp';
4
+
5
+ import { useSessionConversationController } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
6
+
7
+ type TestAgentSendEnvelope = NcpAgentSendEnvelope;
8
+ type TestAgentSend = (envelope: TestAgentSendEnvelope) => Promise<NcpRunHandle | null>;
9
+
10
+ function createTextNode(text: string) {
11
+ return {
12
+ id: 'text-1',
13
+ type: 'text' as const,
14
+ text,
15
+ };
16
+ }
17
+
18
+ function createRunHandle(overrides: Partial<NcpRunHandle> = {}): NcpRunHandle {
19
+ return {
20
+ assistantMessageId: null,
21
+ correlationId: undefined,
22
+ runId: 'run-1',
23
+ sessionId: 'session-1',
24
+ userMessageId: 'user-message-1',
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ function createSendMock() {
30
+ return vi.fn<TestAgentSend>(async () => createRunHandle());
31
+ }
32
+
33
+ function createAgent(params: {
34
+ isRunning: boolean;
35
+ send: ReturnType<typeof createSendMock>;
36
+ }) {
37
+ return {
38
+ abort: vi.fn(),
39
+ isHydrating: false,
40
+ isRunning: params.isRunning,
41
+ isSending: false,
42
+ send: params.send,
43
+ snapshot: {
44
+ activeRun: params.isRunning ? { sessionId: 'session-1' } : null,
45
+ },
46
+ visibleMessages: [],
47
+ };
48
+ }
49
+
50
+ function createControllerParams(params: {
51
+ isRunning: boolean;
52
+ send?: ReturnType<typeof createSendMock>;
53
+ }) {
54
+ const send = params.send ?? createSendMock();
55
+ return {
56
+ agent: createAgent({ isRunning: params.isRunning, send }),
57
+ inputQuery: {
58
+ defaultModel: 'test-model',
59
+ defaultProjectRoot: null,
60
+ fallbackPreferredModel: undefined,
61
+ fallbackPreferredThinking: undefined,
62
+ isProviderStateResolved: true,
63
+ isSkillsLoading: false,
64
+ modelOptions: [],
65
+ selectedSession: null,
66
+ selectedSessionKey: 'session-1',
67
+ sessionTypeState: {
68
+ canEditSessionType: true,
69
+ defaultSessionType: 'default',
70
+ selectedSessionType: 'default',
71
+ selectedSessionTypeOption: null,
72
+ sessionTypeOptions: [],
73
+ sessionTypeUnavailable: false,
74
+ sessionTypeUnavailableMessage: null,
75
+ },
76
+ skillRecords: [],
77
+ },
78
+ inputSnapshot: {
79
+ attachments: [],
80
+ composerFocusRequestId: 0,
81
+ nodes: [createTextNode('next task')],
82
+ pendingProjectRoot: null,
83
+ pendingSessionType: 'default',
84
+ selectedModel: undefined,
85
+ selectedSessionType: 'default',
86
+ selectedSkills: [],
87
+ selectedThinkingLevel: null,
88
+ sendError: null,
89
+ skillRecords: [],
90
+ text: 'next task',
91
+ },
92
+ isRuntimeBlocked: false,
93
+ selectedAgentId: 'main',
94
+ sessionKey: 'session-1',
95
+ resetComposer: vi.fn(),
96
+ restoreComposer: vi.fn(),
97
+ setSendError: vi.fn(),
98
+ };
99
+ }
100
+
101
+ describe('useSessionConversationController queued input delivery', () => {
102
+ afterEach(() => {
103
+ vi.useRealTimers();
104
+ });
105
+
106
+ it('queues a draft while the agent is running and sends it when the run becomes idle', async () => {
107
+ vi.useFakeTimers({ toFake: ['Date'] });
108
+ vi.setSystemTime(new Date('2026-07-05T10:00:00.000Z'));
109
+ const send = createSendMock();
110
+ const { result, rerender } = renderHook(
111
+ ({ isRunning }) => useSessionConversationController(createControllerParams({ isRunning, send })),
112
+ { initialProps: { isRunning: true } },
113
+ );
114
+
115
+ await act(async () => {
116
+ await result.current.send();
117
+ });
118
+
119
+ expect(send).not.toHaveBeenCalled();
120
+ expect(result.current.queuedInputs).toEqual([{ id: expect.any(String), preview: 'next task' }]);
121
+
122
+ vi.setSystemTime(new Date('2026-07-05T10:01:00.000Z'));
123
+ rerender({ isRunning: false });
124
+
125
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
126
+ expect(send.mock.calls[0]?.[0]).toMatchObject({
127
+ message: expect.objectContaining({
128
+ parts: [{ type: 'text', text: 'next task' }],
129
+ timestamp: '2026-07-05T10:01:00.000Z',
130
+ }),
131
+ sessionId: 'session-1',
132
+ });
133
+ expect(send.mock.calls[0]?.[0]).not.toHaveProperty('runDelivery');
134
+ });
135
+
136
+ it('returns a queued draft to the composer for editing and cancels its queue entry', async () => {
137
+ const send = createSendMock();
138
+ const params = createControllerParams({ isRunning: true, send });
139
+ const { result } = renderHook(() => useSessionConversationController(params));
140
+
141
+ await act(async () => {
142
+ await result.current.send();
143
+ });
144
+ const queuedId = result.current.queuedInputs[0]?.id;
145
+ expect(queuedId).toBeTruthy();
146
+
147
+ act(() => {
148
+ result.current.editQueuedInput(queuedId ?? '');
149
+ });
150
+
151
+ expect(result.current.queuedInputs).toEqual([]);
152
+ expect(params.restoreComposer).toHaveBeenCalledWith({
153
+ attachments: [],
154
+ nodes: [createTextNode('next task')],
155
+ selectedSkills: [],
156
+ skillRecords: [],
157
+ text: 'next task',
158
+ });
159
+ expect(send).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('deletes a queued draft without restoring it to the composer', async () => {
163
+ const send = createSendMock();
164
+ const params = createControllerParams({ isRunning: true, send });
165
+ const { result } = renderHook(() => useSessionConversationController(params));
166
+
167
+ await act(async () => {
168
+ await result.current.send();
169
+ });
170
+ const queuedId = result.current.queuedInputs[0]?.id;
171
+ expect(queuedId).toBeTruthy();
172
+
173
+ act(() => {
174
+ result.current.deleteQueuedInput(queuedId ?? '');
175
+ });
176
+
177
+ expect(result.current.queuedInputs).toEqual([]);
178
+ expect(params.restoreComposer).not.toHaveBeenCalled();
179
+ expect(send).not.toHaveBeenCalled();
180
+ });
181
+ });