@nextclaw/ui 0.12.13 → 0.12.15

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 (94) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/assets/{api-C51456xV.js → api-HsS9C7l0.js} +1 -1
  3. package/dist/assets/{app-manager-provider-D_cKqqRG.js → app-manager-provider-CNa6cmOk.js} +1 -1
  4. package/dist/assets/{app-navigation.config-Dve1W20Y.js → app-navigation.config-Dy69P_3O.js} +1 -1
  5. package/dist/assets/{book-open-B4mOKdz8.js → book-open-DJSe9YLj.js} +1 -1
  6. package/dist/assets/{channels-list-page-WJ7d4zMI.js → channels-list-page-DtjYJ1FX.js} +1 -1
  7. package/dist/assets/chat-Rmwi-kQn.js +58 -0
  8. package/dist/assets/{chat-page-CHKwiqPY.js → chat-page-Cslvp6SG.js} +1 -1
  9. package/dist/assets/{chunk-JZWAC4HX-ptDyT_1C.js → chunk-JZWAC4HX-CMPxE3BJ.js} +1 -1
  10. package/dist/assets/{config-split-page-B3PRA_AV.js → config-split-page-v7p1CQjU.js} +1 -1
  11. package/dist/assets/{createLucideIcon-C_GFKVuW.js → createLucideIcon-Y1cWg3R9.js} +1 -1
  12. package/dist/assets/{desktop-update-config-gnna2NaS.js → desktop-update-config-CqJmmpjR.js} +1 -1
  13. package/dist/assets/{dialog-BHcaU6NE.js → dialog-CYis0wx_.js} +1 -1
  14. package/dist/assets/{dist-DtBFqZ6_.js → dist-DIWOVzPY.js} +1 -1
  15. package/dist/assets/{doc-browser-CwgI7ipB.js → doc-browser-BQT0fa3g.js} +1 -1
  16. package/dist/assets/{doc-browser-CoKIUCJj.js → doc-browser-CP6kjGaF.js} +1 -1
  17. package/dist/assets/doc-browser-DK2tXLi_.js +1 -0
  18. package/dist/assets/{doc-browser-context-Dib9sS83.js → doc-browser-context-BacwCbOG.js} +1 -1
  19. package/dist/assets/{es2015-BlNhrQUG.js → es2015-3VnZDFPs.js} +1 -1
  20. package/dist/assets/{external-link-DP2IJ7AM.js → external-link-CPhZo92k.js} +1 -1
  21. package/dist/assets/{folder-BPwc278w.js → folder-orBwjR8h.js} +1 -1
  22. package/dist/assets/{hash-CvcvtMBq.js → hash-BvWW8Scd.js} +1 -1
  23. package/dist/assets/i18n-BhoFaF7E.js +1 -0
  24. package/dist/assets/{index-CxzW1dQ9.js → index-D0_J-bk_.js} +2 -2
  25. package/dist/assets/index-OrUEVLgT.css +1 -0
  26. package/dist/assets/{key-round-BQXmPSxD.js → key-round-B5u2XQwa.js} +1 -1
  27. package/dist/assets/loader-circle-DotuB8NZ.js +1 -0
  28. package/dist/assets/{logo-badge-uB4SwANR.js → logo-badge-BLQdO_th.js} +1 -1
  29. package/dist/assets/{logos-BcELLmYh.js → logos-WPtZ1joO.js} +1 -1
  30. package/dist/assets/marketplace-page-CJHkdgv_.js +1 -0
  31. package/dist/assets/{marketplace-page-DiqqX25V.js → marketplace-page-It54wla_.js} +1 -1
  32. package/dist/assets/{mcp-marketplace-page-C_akqPwv.js → mcp-marketplace-page-C64VUmni.js} +1 -1
  33. package/dist/assets/mcp-marketplace-page-DIItTRy_.js +1 -0
  34. package/dist/assets/message-square-CO_7rU-N.js +1 -0
  35. package/dist/assets/{model-config-B0L43HTL.js → model-config-Cu6zLI8a.js} +1 -1
  36. package/dist/assets/{notice-card-C9PFAR67.js → notice-card-DDVM6IFQ.js} +1 -1
  37. package/dist/assets/play-kSfmJAme.js +1 -0
  38. package/dist/assets/plus-BNaF7Wq5.js +1 -0
  39. package/dist/assets/{popover-B8msg2FQ.js → popover-Dliq2K8O.js} +1 -1
  40. package/dist/assets/{provider-scoped-model-input-DeAo2Y65.js → provider-scoped-model-input-D0oXIrWA.js} +1 -1
  41. package/dist/assets/{providers-list-5_VShcn7.js → providers-list-CrwTWzge.js} +1 -1
  42. package/dist/assets/{refresh-ccw-CeG203yU.js → refresh-ccw-pX3BUxPF.js} +1 -1
  43. package/dist/assets/remote-BpAw88FR.js +1 -0
  44. package/dist/assets/{rotate-cw-F7aThvYj.js → rotate-cw-Bm8W8iaF.js} +1 -1
  45. package/dist/assets/{runtime-config-page-BuWmH7fe.js → runtime-config-page-CYZyCr04.js} +1 -1
  46. package/dist/assets/{save-7ztImRj7.js → save-CZ9zvmBJ.js} +1 -1
  47. package/dist/assets/{search-DZSNKEGp.js → search-DpxqxcpP.js} +1 -1
  48. package/dist/assets/{search-config-DJTm9Fno.js → search-config-D_H6cr07.js} +1 -1
  49. package/dist/assets/{secrets-config-DKFeFii1.js → secrets-config-BG9fic_R.js} +1 -1
  50. package/dist/assets/{select-DRDejPLk.js → select-D-gjRjhr.js} +1 -1
  51. package/dist/assets/{sessions-config-page-DZrdd2zT.js → sessions-config-page-DzSOCrQt.js} +1 -1
  52. package/dist/assets/{setting-row-BcF6eTW0.js → setting-row-Cl4_XpIR.js} +1 -1
  53. package/dist/assets/{settings-DjvNMJde.js → settings-C9WgQdGW.js} +1 -1
  54. package/dist/assets/skeleton-BN6fOana.js +1 -0
  55. package/dist/assets/{sparkles-CyDTgTM4.js → sparkles-gvRilA9f.js} +1 -1
  56. package/dist/assets/{status-dot-aQU9Mia4.js → status-dot-W2TPs1Zf.js} +1 -1
  57. package/dist/assets/{tabs-custom-C4P7g4vR.js → tabs-custom-BxZfWeD1.js} +1 -1
  58. package/dist/assets/{tag-chip-CVIqyMv7.js → tag-chip-DfmhmN50.js} +1 -1
  59. package/dist/assets/{theme-provider-dHqcWU-j.js → theme-provider-CajCgE_N.js} +1 -1
  60. package/dist/assets/{tooltip-C6VPreZ7.js → tooltip-C1XVx_h1.js} +1 -1
  61. package/dist/assets/{trash-2-C1cdqL6V.js → trash-2-WC4Dlakj.js} +1 -1
  62. package/dist/assets/{use-config-DFja1sda.js → use-config-BIErdQNR.js} +1 -1
  63. package/dist/assets/{use-confirm-dialog-DvIbSUX3.js → use-confirm-dialog-B3ZsM4V-.js} +1 -1
  64. package/dist/assets/{use-infinite-scroll-loader-D8h0k-iL.js → use-infinite-scroll-loader-rO55YHGO.js} +1 -1
  65. package/dist/assets/{use-viewport-layout-D-pjxsyz.js → use-viewport-layout-zLAsjh3A.js} +1 -1
  66. package/dist/assets/x-D1867E7F.js +1 -0
  67. package/dist/index.html +39 -39
  68. package/package.json +3 -3
  69. package/src/features/chat/components/chat-session-type-option-item.test.tsx +16 -0
  70. package/src/features/chat/components/chat-session-type-option-item.tsx +4 -1
  71. package/src/features/chat/components/chat-sidebar-session-item.tsx +22 -24
  72. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +18 -1
  73. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +2 -1
  74. package/src/features/chat/components/layout/chat-sidebar.test.tsx +45 -1
  75. package/src/features/chat/components/layout/chat-sidebar.tsx +36 -45
  76. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +29 -0
  77. package/src/features/chat/utils/chat-input-bar.utils.test.ts +27 -1
  78. package/src/features/chat/utils/chat-input-toolbar.utils.ts +20 -19
  79. package/src/features/chat/utils/ncp-chat-input-availability.utils.test.ts +35 -3
  80. package/src/features/chat/utils/ncp-chat-input-availability.utils.ts +4 -6
  81. package/src/shared/lib/i18n/chat.ts +1 -1
  82. package/dist/assets/chat-DCi1-y8U.js +0 -58
  83. package/dist/assets/doc-browser-DYKpRqe-.js +0 -1
  84. package/dist/assets/i18n-BnNAQpVM.js +0 -1
  85. package/dist/assets/index-mRmSAB-e.css +0 -1
  86. package/dist/assets/loader-circle-C6gg2m2a.js +0 -1
  87. package/dist/assets/marketplace-page-0sEdt5sA.js +0 -1
  88. package/dist/assets/mcp-marketplace-page-B8vmu9xe.js +0 -1
  89. package/dist/assets/message-square-CLVODA23.js +0 -1
  90. package/dist/assets/play-DeNVUA5C.js +0 -1
  91. package/dist/assets/plus-BptLViq1.js +0 -1
  92. package/dist/assets/remote-pzp4oLcL.js +0 -1
  93. package/dist/assets/skeleton-5Mg6vZHN.js +0 -1
  94. package/dist/assets/x-BjMO7v8q.js +0 -1
@@ -7,7 +7,8 @@ import { useChatInputStore } from '@/features/chat/stores/chat-input.store';
7
7
  import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
8
8
 
9
9
  const mocks = vi.hoisted(() => ({
10
- createSession: vi.fn(),
10
+ createSession: vi.fn(() => 'draft-session-key'),
11
+ goToSession: vi.fn(),
11
12
  setQuery: vi.fn(),
12
13
  setListMode: vi.fn(),
13
14
  selectSession: vi.fn(),
@@ -28,6 +29,9 @@ function createSessionItem(
28
29
 
29
30
  vi.mock('@/features/chat/components/providers/chat-presenter.provider', () => ({
30
31
  usePresenter: () => ({
32
+ chatUiManager: {
33
+ goToSession: mocks.goToSession,
34
+ },
31
35
  chatSessionListManager: {
32
36
  createSession: mocks.createSession,
33
37
  setQuery: mocks.setQuery,
@@ -115,6 +119,8 @@ vi.mock('@/features/system-status', () => ({
115
119
 
116
120
  function resetSidebarTestState() {
117
121
  mocks.createSession.mockReset();
122
+ mocks.createSession.mockReturnValue('draft-session-key');
123
+ mocks.goToSession.mockReset();
118
124
  mocks.setQuery.mockReset();
119
125
  mocks.setListMode.mockReset();
120
126
  mocks.selectSession.mockReset();
@@ -228,6 +234,7 @@ describe('ChatSidebar create and list basics', () => {
228
234
 
229
235
  expect(mocks.setQuery).toHaveBeenCalledWith('release notes');
230
236
  expect(mocks.createSession).toHaveBeenCalledWith('codex');
237
+ expect(mocks.goToSession).toHaveBeenCalledWith('draft-session-key');
231
238
  });
232
239
 
233
240
  it('creates the default session directly from the compact mobile add button when no menu is needed', () => {
@@ -248,6 +255,7 @@ describe('ChatSidebar create and list basics', () => {
248
255
  fireEvent.click(screen.getByRole('button', { name: 'New Task' }));
249
256
 
250
257
  expect(mocks.createSession).toHaveBeenCalledWith('native');
258
+ expect(mocks.goToSession).toHaveBeenCalledWith('draft-session-key');
251
259
  });
252
260
 
253
261
  it('shows a session type badge for non-native sessions in the list', () => {
@@ -408,6 +416,7 @@ describe('ChatSidebar project-first mode', () => {
408
416
  fireEvent.click(screen.getByText('Codex'));
409
417
 
410
418
  expect(mocks.createSession).toHaveBeenCalledWith('codex', '/tmp/project-beta');
419
+ expect(mocks.goToSession).not.toHaveBeenCalled();
411
420
  });
412
421
 
413
422
  it('creates immediately when there is only one available runtime type', () => {
@@ -447,6 +456,41 @@ describe('ChatSidebar project-first mode', () => {
447
456
  fireEvent.click(screen.getByRole('button', { name: 'New Task · project-gamma' }));
448
457
 
449
458
  expect(mocks.createSession).toHaveBeenCalledWith('native', '/tmp/project-gamma');
459
+ expect(mocks.goToSession).not.toHaveBeenCalled();
460
+ });
461
+
462
+ it('opens the draft detail after creating a project-bound session on mobile', () => {
463
+ useChatSessionListStore.setState({
464
+ snapshot: {
465
+ ...useChatSessionListStore.getState().snapshot,
466
+ listMode: 'project-first'
467
+ }
468
+ });
469
+ mocks.sessionItems = [
470
+ createSessionItem({
471
+ key: 'session:project-mobile-1',
472
+ createdAt: '2026-03-19T09:00:00.000Z',
473
+ updatedAt: '2026-03-19T11:05:00.000Z',
474
+ label: 'Grouped Mobile Task',
475
+ projectRoot: '/tmp/project-mobile',
476
+ projectName: 'project-mobile',
477
+ sessionType: 'native',
478
+ sessionTypeMutable: false,
479
+ messageCount: 2
480
+ })
481
+ ];
482
+
483
+ render(
484
+ <MemoryRouter>
485
+ <ChatSidebar variant="mobile" />
486
+ </MemoryRouter>
487
+ );
488
+
489
+ fireEvent.click(screen.getByRole('button', { name: 'New Task · project-mobile' }));
490
+ fireEvent.click(screen.getByText('Codex'));
491
+
492
+ expect(mocks.createSession).toHaveBeenCalledWith('codex', '/tmp/project-mobile');
493
+ expect(mocks.goToSession).toHaveBeenCalledWith('draft-session-key');
450
494
  });
451
495
  });
452
496
 
@@ -8,10 +8,7 @@ import { useChatSidebarSessionLabelEditor } from '@/features/chat/hooks/use-chat
8
8
  import { useNcpSessionListView, type NcpSessionListItemView } from '@/features/chat/hooks/use-ncp-session-list-view';
9
9
  import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
10
10
  import { useChatInputStore } from '@/features/chat/stores/chat-input.store';
11
- import {
12
- shouldShowUnreadSessionIndicator,
13
- useChatSessionListStore
14
- } from '@/features/chat/stores/chat-session-list.store';
11
+ import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
15
12
  import { useSystemStatus } from '@/features/system-status';
16
13
  import { useAgents } from '@/shared/hooks/use-agents';
17
14
  import { getSessionProjectName } from '@/shared/lib/session-project';
@@ -230,32 +227,33 @@ export function ChatSidebar({
230
227
  setLanguage(nextLang);
231
228
  window.location.reload();
232
229
  };
233
- const renderSessionItem = (item: NcpSessionListItemView) =>
234
- (
235
- <ChatSidebarSessionEntry
236
- key={item.session.key}
237
- item={item}
238
- selectedSessionKey={listSnapshot.selectedSessionKey}
239
- optimisticReadAtBySessionKey={optimisticReadAtBySessionKey}
240
- agentsById={agentsById}
241
- childSessionsByParentKey={childSessionsByParentKey}
242
- editingSessionKey={editingSessionKey}
243
- draftLabel={draftLabel}
244
- savingSessionKey={savingSessionKey}
245
- sessionTitle={sessionTitle}
246
- onSelectSession={presenter.chatSessionListManager.selectSession}
247
- onOpenChildSessions={(parentSessionKey, activeChildSessionKey) =>
248
- presenter.chatThreadManager.openChildSessionPanel({
249
- parentSessionKey,
250
- activeChildSessionKey,
251
- })
252
- }
253
- onStartEditingSessionLabel={startEditingSessionLabel}
254
- onDraftLabelChange={setDraftLabel}
255
- onSaveSessionLabel={saveSessionLabel}
256
- onCancelEditingSessionLabel={cancelEditingSessionLabel}
257
- />
258
- );
230
+ const renderSessionItem = (item: NcpSessionListItemView) => (
231
+ <ChatSidebarSessionEntry
232
+ key={item.session.key}
233
+ item={item}
234
+ selectedSessionKey={listSnapshot.selectedSessionKey}
235
+ optimisticReadAtBySessionKey={optimisticReadAtBySessionKey}
236
+ agentsById={agentsById}
237
+ childSessionsByParentKey={childSessionsByParentKey}
238
+ editingSessionKey={editingSessionKey}
239
+ draftLabel={draftLabel}
240
+ savingSessionKey={savingSessionKey}
241
+ sessionTitle={sessionTitle}
242
+ onSelectSession={presenter.chatSessionListManager.selectSession}
243
+ onOpenChildSessions={(parentSessionKey, activeChildSessionKey) => presenter.chatThreadManager.openChildSessionPanel({ parentSessionKey, activeChildSessionKey })}
244
+ onStartEditingSessionLabel={startEditingSessionLabel}
245
+ onDraftLabelChange={setDraftLabel}
246
+ onSaveSessionLabel={saveSessionLabel}
247
+ onCancelEditingSessionLabel={cancelEditingSessionLabel}
248
+ />
249
+ );
250
+ const createSessionAndOpenIfNeeded = (sessionType: string, projectRoot?: string | null) => {
251
+ const sessionKey = typeof projectRoot === "string"
252
+ ? presenter.chatSessionListManager.createSession(sessionType, projectRoot)
253
+ : presenter.chatSessionListManager.createSession(sessionType);
254
+ if (isMobileVariant) presenter.chatUiManager.goToSession(sessionKey);
255
+ };
256
+
259
257
  return (
260
258
  <aside
261
259
  className={cn(
@@ -282,7 +280,7 @@ export function ChatSidebar({
282
280
  nonDefaultSessionTypeOptions={nonDefaultSessionTypeOptions}
283
281
  isCreateMenuOpen={isCreateMenuOpen}
284
282
  onCreateMenuOpenChange={setIsCreateMenuOpen}
285
- onCreateSession={presenter.chatSessionListManager.createSession}
283
+ onCreateSession={createSessionAndOpenIfNeeded}
286
284
  onQueryChange={presenter.chatSessionListManager.setQuery}
287
285
  />
288
286
  ) : (
@@ -293,7 +291,7 @@ export function ChatSidebar({
293
291
  nonDefaultSessionTypeOptions={nonDefaultSessionTypeOptions}
294
292
  isCreateMenuOpen={isCreateMenuOpen}
295
293
  onCreateMenuOpenChange={setIsCreateMenuOpen}
296
- onCreateSession={presenter.chatSessionListManager.createSession}
294
+ onCreateSession={createSessionAndOpenIfNeeded}
297
295
  onQueryChange={presenter.chatSessionListManager.setQuery}
298
296
  />
299
297
  )}
@@ -301,18 +299,11 @@ export function ChatSidebar({
301
299
  {!isMobileVariant ? (
302
300
  <div className="px-3 pb-2">
303
301
  <ul className="space-y-0.5">
304
- {navItems.map((item) => {
305
- return (
306
- <li key={item.target}>
307
- <SidebarNavLinkItem
308
- to={item.target}
309
- label={item.label()}
310
- icon={item.icon}
311
- density="compact"
312
- />
313
- </li>
314
- );
315
- })}
302
+ {navItems.map((item) => (
303
+ <li key={item.target}>
304
+ <SidebarNavLinkItem to={item.target} label={item.label()} icon={item.icon} density="compact" />
305
+ </li>
306
+ ))}
316
307
  </ul>
317
308
  </div>
318
309
  ) : null}
@@ -346,7 +337,7 @@ export function ChatSidebar({
346
337
  defaultSessionType={defaultSessionType}
347
338
  sessionTypeOptions={inputSnapshot.sessionTypeOptions}
348
339
  renderSessionItem={renderSessionItem}
349
- onCreateSession={presenter.chatSessionListManager.createSession}
340
+ onCreateSession={createSessionAndOpenIfNeeded}
350
341
  />
351
342
  )
352
343
  ) : groups.length === 0 ? (
@@ -146,4 +146,33 @@ describe('NcpChatInputManager', () => {
146
146
  expect(streamActionsManager.sendMessage).not.toHaveBeenCalled();
147
147
  expect(sessionListManager.promoteRootDraftSessionRoute).not.toHaveBeenCalled();
148
148
  });
149
+
150
+ it('still attempts to send when provider metadata is stale or the session type is marked unavailable', async () => {
151
+ useChatInputStore.setState({
152
+ snapshot: {
153
+ ...useChatInputStore.getState().snapshot,
154
+ isProviderStateResolved: true,
155
+ modelOptions: [],
156
+ sessionTypeUnavailable: true,
157
+ },
158
+ });
159
+ const streamActionsManager = {
160
+ sendMessage: vi.fn().mockResolvedValue(undefined),
161
+ stopCurrentRun: vi.fn().mockResolvedValue(undefined),
162
+ } as unknown as ConstructorParameters<typeof NcpChatInputManager>[1];
163
+ const sessionListManager = {
164
+ ensureDraftSession: vi.fn(() => 'draft-session'),
165
+ promoteRootDraftSessionRoute: vi.fn(),
166
+ } as unknown as ConstructorParameters<typeof NcpChatInputManager>[2];
167
+ const manager = new NcpChatInputManager(
168
+ {} as ConstructorParameters<typeof NcpChatInputManager>[0],
169
+ streamActionsManager,
170
+ sessionListManager,
171
+ );
172
+
173
+ await manager.send();
174
+
175
+ expect(streamActionsManager.sendMessage).toHaveBeenCalledTimes(1);
176
+ expect(sessionListManager.promoteRootDraftSessionRoute).toHaveBeenCalledWith('current-route-session');
177
+ });
149
178
  });
@@ -212,13 +212,39 @@ describe('buildModelToolbarSelect', () => {
212
212
  });
213
213
 
214
214
  expect(select.value).toBe('minimax/MiniMax-M2.7');
215
- expect(select.selectedLabel).toBe('MiniMax/MiniMax-M2.7');
215
+ expect(select.selectedLabel).toBe('MiniMax-M2.7');
216
216
  expect(select.options[0]).toEqual({
217
217
  value: 'minimax/MiniMax-M2.7',
218
218
  label: 'MiniMax/MiniMax-M2.7'
219
219
  });
220
220
  });
221
221
 
222
+ it('keeps provider labels in the dropdown while using only the model name in the trigger', () => {
223
+ const select = buildModelToolbarSelect({
224
+ modelOptions: [
225
+ {
226
+ value: 'anthropic/claude-sonnet-4-very-long-name',
227
+ modelLabel: 'claude-sonnet-4-very-long-name',
228
+ providerLabel: 'Anthropic'
229
+ }
230
+ ],
231
+ recentModelValues: [],
232
+ selectedModel: 'anthropic/claude-sonnet-4-very-long-name',
233
+ isModelOptionsLoading: false,
234
+ hasModelOptions: true,
235
+ onValueChange: vi.fn(),
236
+ texts: {
237
+ modelSelectPlaceholder: 'Select model',
238
+ modelNoOptionsLabel: 'No models',
239
+ recentModelsLabel: 'Recent',
240
+ allModelsLabel: 'All models'
241
+ }
242
+ });
243
+
244
+ expect(select.selectedLabel).toBe('claude-sonnet-4-very-long-name');
245
+ expect(select.options[0]?.label).toBe('Anthropic/claude-sonnet-4-very-long-name');
246
+ });
247
+
222
248
  it('groups recent models ahead of the remaining catalog', () => {
223
249
  const select = buildModelToolbarSelect({
224
250
  modelOptions: [
@@ -52,7 +52,9 @@ export function buildModelStateHint(params: {
52
52
  };
53
53
  }
54
54
 
55
- export function buildModelToolbarSelect(params: {
55
+ export function buildModelToolbarSelect({
56
+ modelOptions, recentModelValues, selectedModel, isModelOptionsLoading, hasModelOptions, onValueChange, texts,
57
+ }: {
56
58
  modelOptions: ChatModelRecord[];
57
59
  recentModelValues?: string[];
58
60
  selectedModel: string;
@@ -67,22 +69,21 @@ export function buildModelToolbarSelect(params: {
67
69
  | "allModelsLabel"
68
70
  >;
69
71
  }): ChatToolbarSelect {
70
- const selectedModelOption = params.modelOptions.find(
71
- (option) => option.value === params.selectedModel,
72
+ const selectedModelOption = modelOptions.find(
73
+ (option) => option.value === selectedModel,
72
74
  );
73
- const fallbackModelOption = params.modelOptions[0];
74
- const resolvedModelOption = selectedModelOption ?? fallbackModelOption;
75
- const resolvedValue = params.hasModelOptions
75
+ const resolvedModelOption = selectedModelOption ?? modelOptions[0];
76
+ const resolvedValue = hasModelOptions
76
77
  ? resolvedModelOption?.value
77
78
  : undefined;
78
- const recentValueSet = new Set(params.recentModelValues ?? []);
79
+ const recentValueSet = new Set(recentModelValues ?? []);
79
80
  const modelOptionMap = new Map(
80
- params.modelOptions.map((option) => [option.value, option] as const),
81
+ modelOptions.map((option) => [option.value, option] as const),
81
82
  );
82
- const recentOptions = (params.recentModelValues ?? [])
83
+ const recentOptions = (recentModelValues ?? [])
83
84
  .map((value) => modelOptionMap.get(value))
84
85
  .filter((option): option is ChatModelRecord => Boolean(option));
85
- const remainingOptions = params.modelOptions.filter(
86
+ const remainingOptions = modelOptions.filter(
86
87
  (option) => !recentValueSet.has(option.value),
87
88
  );
88
89
  const optionGroups =
@@ -90,7 +91,7 @@ export function buildModelToolbarSelect(params: {
90
91
  ? [
91
92
  {
92
93
  key: "recent-models",
93
- label: params.texts.recentModelsLabel,
94
+ label: texts.recentModelsLabel,
94
95
  options: recentOptions.map((option) => ({
95
96
  value: option.value,
96
97
  label: formatModelOptionLabel(option),
@@ -98,7 +99,7 @@ export function buildModelToolbarSelect(params: {
98
99
  },
99
100
  {
100
101
  key: "all-models",
101
- label: params.texts.allModelsLabel,
102
+ label: texts.allModelsLabel,
102
103
  options: remainingOptions.map((option) => ({
103
104
  value: option.value,
104
105
  label: formatModelOptionLabel(option),
@@ -110,20 +111,20 @@ export function buildModelToolbarSelect(params: {
110
111
  return {
111
112
  key: "model",
112
113
  value: resolvedValue,
113
- placeholder: params.texts.modelSelectPlaceholder,
114
+ placeholder: texts.modelSelectPlaceholder,
114
115
  selectedLabel: resolvedModelOption
115
- ? formatModelOptionLabel(resolvedModelOption)
116
+ ? resolvedModelOption.modelLabel.trim() || formatModelOptionLabel(resolvedModelOption)
116
117
  : undefined,
117
118
  icon: "sparkles",
118
- options: params.modelOptions.map((option) => ({
119
+ options: modelOptions.map((option) => ({
119
120
  value: option.value,
120
121
  label: formatModelOptionLabel(option),
121
122
  })),
122
123
  groups: optionGroups,
123
- disabled: !params.hasModelOptions,
124
- loading: params.isModelOptionsLoading,
125
- emptyLabel: params.texts.modelNoOptionsLabel,
126
- onValueChange: params.onValueChange,
124
+ disabled: !hasModelOptions,
125
+ loading: isModelOptionsLoading,
126
+ emptyLabel: texts.modelNoOptionsLabel,
127
+ onValueChange,
127
128
  };
128
129
  }
129
130
 
@@ -41,7 +41,7 @@ function createSnapshot(
41
41
  }
42
42
 
43
43
  describe('ncp-chat-input-availability.utils', () => {
44
- it('keeps the composer editable during cold start while send remains blocked', () => {
44
+ it('keeps the composer editable during cold start while runtime blocking still prevents send', () => {
45
45
  const snapshot = createSnapshot({
46
46
  isProviderStateResolved: false,
47
47
  modelOptions: [],
@@ -60,6 +60,22 @@ describe('ncp-chat-input-availability.utils', () => {
60
60
  ).toBe(true);
61
61
  });
62
62
 
63
+ it('does not block send only because model options have not loaded yet', () => {
64
+ const snapshot = createSnapshot({
65
+ isProviderStateResolved: false,
66
+ modelOptions: [],
67
+ sessionTypeUnavailable: false,
68
+ });
69
+
70
+ expect(
71
+ isNcpChatSendDisabled({
72
+ snapshot,
73
+ hasSendableDraft: true,
74
+ isRuntimeBlocked: false,
75
+ })
76
+ ).toBe(false);
77
+ });
78
+
63
79
  it('marks model options as empty only after provider state resolves', () => {
64
80
  const loadingSnapshot = createSnapshot({
65
81
  isProviderStateResolved: false,
@@ -74,19 +90,35 @@ describe('ncp-chat-input-availability.utils', () => {
74
90
  expect(isNcpChatModelOptionsEmpty(emptySnapshot)).toBe(true);
75
91
  });
76
92
 
77
- it('disables both editing and sending when the session type is unavailable', () => {
93
+ it('keeps editing and sending available when the selected session type reports unavailable', () => {
78
94
  const snapshot = createSnapshot({
79
95
  isProviderStateResolved: true,
80
96
  sessionTypeUnavailable: true,
81
97
  });
82
98
 
83
- expect(isNcpChatComposerDisabled(snapshot)).toBe(true);
99
+ expect(isNcpChatComposerDisabled(snapshot)).toBe(false);
84
100
  expect(
85
101
  isNcpChatSendDisabled({
86
102
  snapshot,
87
103
  hasSendableDraft: true,
88
104
  isRuntimeBlocked: false,
89
105
  })
106
+ ).toBe(false);
107
+ });
108
+
109
+ it('blocks send when there is no sendable draft', () => {
110
+ const snapshot = createSnapshot({
111
+ isProviderStateResolved: true,
112
+ modelOptions: [],
113
+ sessionTypeUnavailable: true,
114
+ });
115
+
116
+ expect(
117
+ isNcpChatSendDisabled({
118
+ snapshot,
119
+ hasSendableDraft: false,
120
+ isRuntimeBlocked: false,
121
+ })
90
122
  ).toBe(true);
91
123
  });
92
124
  });
@@ -24,9 +24,9 @@ export function isNcpChatModelOptionsEmpty(
24
24
  }
25
25
 
26
26
  export function isNcpChatComposerDisabled(
27
- snapshot: NcpChatInputAvailabilitySnapshot
27
+ _snapshot: NcpChatInputAvailabilitySnapshot
28
28
  ): boolean {
29
- return snapshot.sessionTypeUnavailable;
29
+ return false;
30
30
  }
31
31
 
32
32
  export function isNcpChatSendDisabled(params: {
@@ -34,12 +34,10 @@ export function isNcpChatSendDisabled(params: {
34
34
  snapshot: NcpChatInputAvailabilitySnapshot;
35
35
  isRuntimeBlocked: boolean;
36
36
  }): boolean {
37
- const { hasSendableDraft, isRuntimeBlocked, snapshot } = params;
37
+ const { hasSendableDraft, isRuntimeBlocked } = params;
38
38
 
39
39
  return (
40
40
  isRuntimeBlocked ||
41
- !hasSendableDraft ||
42
- !hasNcpChatModelOptions(snapshot) ||
43
- snapshot.sessionTypeUnavailable
41
+ !hasSendableDraft
44
42
  );
45
43
  }
@@ -67,7 +67,7 @@ export const CHAT_LABELS: Record<string, { zh: string; en: string }> = {
67
67
  zh: '聊天能力启动失败,请稍后重试或检查服务日志。',
68
68
  en: 'Chat startup failed. Please retry in a moment or inspect the service logs.'
69
69
  },
70
- chatInputPlaceholder: { zh: '输入消息,输入 / 选择技能,Enter 发送,Shift + Enter 换行', en: 'Type a message, type / to select skills, Enter to send, Shift + Enter for newline' },
70
+ chatInputPlaceholder: { zh: '发消息...', en: 'Message NextClaw...' },
71
71
  chatInputHint: { zh: '支持多轮上下文,默认走当前会话。', en: 'Multi-turn context is preserved in the current session.' },
72
72
  chatSlashSectionCommands: { zh: '命令', en: 'Commands' },
73
73
  chatSlashSectionSkills: { zh: '技能', en: 'Skills' },