@nextclaw/ui 0.12.19 → 0.12.20-beta.1

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 (185) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/assets/api-BcqDx0tm.js +15 -0
  3. package/dist/assets/app-manager-provider-DVYBjif-.js +1 -0
  4. package/dist/assets/app-navigation.config-CMoWvFEI.js +1 -0
  5. package/dist/assets/{book-open-CVEuA0y5.js → book-open-DgLqYpNY.js} +1 -1
  6. package/dist/assets/{channels-list-page-BqhqaBf1.js → channels-list-page-CsoI4OJm.js} +2 -2
  7. package/dist/assets/{chat-D4KecKjB.js → chat-CA3aRmhx.js} +13 -12
  8. package/dist/assets/chat-page-gdSN6Pr6.js +1 -0
  9. package/dist/assets/chunk-JZWAC4HX-u4uYphxM.js +3 -0
  10. package/dist/assets/{config-split-page-BGjVACdO.js → config-split-page-BMRGuCJQ.js} +1 -1
  11. package/dist/assets/{createLucideIcon-PPrXCGK8.js → createLucideIcon-BZkY6emz.js} +1 -1
  12. package/dist/assets/desktop-update-config-CD6-2PfI.js +1 -0
  13. package/dist/assets/{dialog-CTCX7oLf.js → dialog-csshWetU.js} +1 -1
  14. package/dist/assets/{dist-FL5e8mMi.js → dist-Bl94Ahwx.js} +1 -1
  15. package/dist/assets/{doc-browser-C02neCIE.js → doc-browser-BUlCkZo2.js} +1 -1
  16. package/dist/assets/doc-browser-CzCV73NJ.js +1 -0
  17. package/dist/assets/doc-browser-Doh2541x.js +1 -0
  18. package/dist/assets/{doc-browser-context-C-WPOji4.js → doc-browser-context-DfLHAWbG.js} +1 -1
  19. package/dist/assets/{es2015-BNy4R8AC.js → es2015-JCM5-KtW.js} +1 -1
  20. package/dist/assets/{external-link-BNtqJE01.js → external-link-Sw3ah_JD.js} +1 -1
  21. package/dist/assets/{folder-QyJHVUNz.js → folder-D7-VTnkz.js} +1 -1
  22. package/dist/assets/{hash-BGYUE-zr.js → hash-zajSTDXZ.js} +1 -1
  23. package/dist/assets/i18n-C5Mibli1.js +1 -0
  24. package/dist/assets/index-BTDFuKka.js +2 -0
  25. package/dist/assets/index-CUmk8xFK.css +1 -0
  26. package/dist/assets/{key-round-DenCfA2w.js → key-round-CnI1mc9F.js} +1 -1
  27. package/dist/assets/loader-circle-B5i8oMMY.js +1 -0
  28. package/dist/assets/{logo-badge-CKAxvQFc.js → logo-badge-BQgKnVtz.js} +1 -1
  29. package/dist/assets/{logos-CqXnaJIm.js → logos-CqVm0q0W.js} +1 -1
  30. package/dist/assets/marketplace-page-DJGDpTAo.js +1 -0
  31. package/dist/assets/{marketplace-page-XnDa2ulT.js → marketplace-page-DxlxHCFm.js} +2 -2
  32. package/dist/assets/mcp-marketplace-page-5UjYRWOR.js +40 -0
  33. package/dist/assets/mcp-marketplace-page-C1XaHZZO.js +1 -0
  34. package/dist/assets/message-square-D6Z4NwpG.js +1 -0
  35. package/dist/assets/{model-config-ByeL6Toe.js → model-config-PccJ9XyH.js} +1 -1
  36. package/dist/assets/{notice-card-D00-02yg.js → notice-card-CCgk6FvF.js} +1 -1
  37. package/dist/assets/play-D8WJLnJe.js +1 -0
  38. package/dist/assets/plus-Di0KAkiO.js +1 -0
  39. package/dist/assets/{popover-AmJkxio3.js → popover-YAsxDBhY.js} +1 -1
  40. package/dist/assets/{provider-scoped-model-input-CfFJsJp-.js → provider-scoped-model-input-CzpF7cug.js} +1 -1
  41. package/dist/assets/{providers-list-HMQzW2WV.js → providers-list-8qDMER8o.js} +1 -1
  42. package/dist/assets/{refresh-ccw-B-dhb3yS.js → refresh-ccw-Bii4w8aB.js} +1 -1
  43. package/dist/assets/refresh-cw-BxojR62w.js +1 -0
  44. package/dist/assets/remote-D4TtLPAp.js +1 -0
  45. package/dist/assets/{rotate-cw-BWqAG3Fv.js → rotate-cw-1Xqa7LZ8.js} +1 -1
  46. package/dist/assets/runtime-config-page-D-4c5H5z.js +1 -0
  47. package/dist/assets/{save-DpdkGieJ.js → save--BVI5wZX.js} +1 -1
  48. package/dist/assets/search-config-D3a65l3r.js +1 -0
  49. package/dist/assets/{search-CQUdr7j_.js → search-vChioOoe.js} +1 -1
  50. package/dist/assets/{secrets-config-YCsGd1am.js → secrets-config-CoMlR_7i.js} +2 -2
  51. package/dist/assets/{select-DVUtSFHZ.js → select-DIZrwsKU.js} +1 -1
  52. package/dist/assets/{sessions-config-page-BKN-XdKr.js → sessions-config-page-Cc0TJStn.js} +2 -2
  53. package/dist/assets/{setting-row-Cb5-lFs-.js → setting-row-DiQyrE81.js} +1 -1
  54. package/dist/assets/{settings-DgtZZlnF.js → settings-CiRChctQ.js} +1 -1
  55. package/dist/assets/skeleton-CFQRIUzt.js +1 -0
  56. package/dist/assets/{sparkles-DNSCyDhL.js → sparkles-D1ZKWdm4.js} +1 -1
  57. package/dist/assets/{status-dot-X_j51OfA.js → status-dot-Dv_hiUVa.js} +1 -1
  58. package/dist/assets/{tabs-custom-CcWmekaF.js → tabs-custom-CsACkVji.js} +1 -1
  59. package/dist/assets/{tag-chip-fdbK2wE6.js → tag-chip-C3wDBe_-.js} +1 -1
  60. package/dist/assets/theme-provider-aOmrJ9J6.js +1 -0
  61. package/dist/assets/{tooltip-BkZCQcKw.js → tooltip-Dq5Xehpk.js} +1 -1
  62. package/dist/assets/{trash-2-CqciSCsg.js → trash-2-rY9ZteZX.js} +1 -1
  63. package/dist/assets/use-config-BQJjq1mP.js +1 -0
  64. package/dist/assets/{use-confirm-dialog-DSrb9205.js → use-confirm-dialog-DBoV5n5P.js} +1 -1
  65. package/dist/assets/{use-infinite-scroll-loader-DmowtyTI.js → use-infinite-scroll-loader-JAicqVC5.js} +1 -1
  66. package/dist/assets/{use-viewport-layout-CaALCA51.js → use-viewport-layout-BX3XqzJ4.js} +1 -1
  67. package/dist/assets/x-DpTzXQcX.js +1 -0
  68. package/dist/index.html +40 -39
  69. package/package.json +9 -6
  70. package/src/app/hooks/use-realtime-query-bridge.ts +5 -5
  71. package/src/app/index.tsx +7 -1
  72. package/src/features/channels/components/config/channel-form.tsx +3 -3
  73. package/src/features/channels/components/config/weixin-channel-auth-section.tsx +1 -1
  74. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +1 -0
  75. package/src/features/chat/components/conversation/chat-input-bar.container.tsx +9 -4
  76. package/src/features/chat/components/conversation/chat-message-list.container.test.tsx +64 -6
  77. package/src/features/chat/components/conversation/chat-message-list.container.tsx +185 -17
  78. package/src/features/chat/components/session/session-context-icon.tsx +1 -4
  79. package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +3 -1
  80. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +7 -6
  81. package/src/features/chat/hooks/use-ncp-session-conversation.test.tsx +74 -2
  82. package/src/features/chat/hooks/use-ncp-session-conversation.ts +32 -10
  83. package/src/features/chat/hooks/use-selected-session-context-window-indicator.ts +20 -0
  84. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +25 -0
  85. package/src/features/chat/managers/ncp-chat-input.manager.ts +5 -1
  86. package/src/features/chat/pages/ncp-chat-page.test.ts +22 -8
  87. package/src/features/chat/pages/ncp-chat-page.tsx +15 -11
  88. package/src/features/chat/stores/chat-thread.store.ts +8 -2
  89. package/src/features/chat/utils/chat-context-window-indicator.utils.ts +50 -0
  90. package/src/features/chat/utils/chat-runtime.utils.ts +1 -1
  91. package/src/features/chat/utils/chat-session-preference-governance.utils.test.tsx +114 -0
  92. package/src/features/chat/utils/chat-session-preference-governance.utils.ts +30 -36
  93. package/src/features/chat/utils/ncp-chat-runtime-availability.utils.test.ts +165 -0
  94. package/src/features/chat/utils/ncp-chat-runtime-availability.utils.ts +50 -0
  95. package/src/features/chat/utils/ncp-session-adapter.utils.test.ts +27 -0
  96. package/src/features/chat/utils/ncp-session-adapter.utils.ts +6 -4
  97. package/src/features/chat/utils/ncp-session-context-metadata.utils.ts +121 -0
  98. package/src/features/chat/utils/session-context.utils.ts +1 -2
  99. package/src/features/system-status/components/config/runtime-config-editor.tsx +6 -0
  100. package/src/features/system-status/components/config/runtime-settings-card.tsx +12 -0
  101. package/src/features/system-status/components/desktop-update-config.test.tsx +17 -7
  102. package/src/features/system-status/components/desktop-update-config.tsx +75 -30
  103. package/src/features/system-status/hooks/use-system-status.ts +0 -11
  104. package/src/features/system-status/index.ts +4 -1
  105. package/src/features/system-status/managers/runtime-update.manager.ts +330 -0
  106. package/src/features/system-status/managers/system-status.manager.test.ts +0 -25
  107. package/src/features/system-status/managers/system-status.manager.ts +1 -30
  108. package/src/features/system-status/stores/runtime-update.store.ts +24 -0
  109. package/src/features/system-status/types/system-status.types.ts +0 -2
  110. package/src/features/system-status/utils/runtime-config-agent.utils.ts +6 -1
  111. package/src/features/system-status/utils/system-status.utils.test.ts +1 -85
  112. package/src/features/system-status/utils/system-status.utils.ts +1 -23
  113. package/src/platforms/desktop/managers/desktop-update.manager.ts +6 -0
  114. package/src/platforms/desktop/types/desktop-update.types.ts +21 -19
  115. package/src/shared/components/common/brand-header.test.tsx +142 -0
  116. package/src/shared/components/common/brand-header.tsx +93 -0
  117. package/src/shared/components/cron-config.tsx +1 -1
  118. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +1 -1
  119. package/src/shared/components/doc-browser/doc-browser.tsx +1 -1
  120. package/src/shared/components/search-config.tsx +3 -3
  121. package/src/shared/lib/api/README.md +3 -0
  122. package/src/shared/lib/api/index.ts +13 -11
  123. package/src/shared/lib/api/ncp-session.test.ts +17 -18
  124. package/src/shared/lib/api/ncp-session.types.ts +92 -0
  125. package/src/shared/lib/api/raw-client.utils.ts +3 -126
  126. package/src/shared/lib/api/services/agents.service.ts +18 -0
  127. package/src/shared/lib/api/services/channel-auth.service.ts +21 -0
  128. package/src/shared/lib/api/{client.ts → services/client.service.ts} +45 -1
  129. package/src/shared/lib/api/services/config.service.ts +171 -0
  130. package/src/shared/lib/api/services/marketplace.service.ts +66 -0
  131. package/src/shared/lib/api/services/mcp-marketplace.service.ts +70 -0
  132. package/src/shared/lib/api/services/ncp-attachments.service.ts +14 -0
  133. package/src/shared/lib/api/services/ncp-session.service.ts +39 -0
  134. package/src/shared/lib/api/services/remote.service.ts +50 -0
  135. package/src/shared/lib/api/services/runtime-control.service.ts +18 -0
  136. package/src/shared/lib/api/services/runtime-update.service.ts +26 -0
  137. package/src/shared/lib/api/services/server-path.service.ts +16 -0
  138. package/src/shared/lib/api/types.ts +9 -74
  139. package/src/shared/lib/i18n/{chat.ts → chat-labels.utils.ts} +13 -1
  140. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +65 -0
  141. package/src/shared/lib/i18n/index.ts +4 -5
  142. package/src/shared/lib/i18n/runtime/i18n-language-owner.ts +5 -5
  143. package/src/shared/lib/transport/index.ts +1 -0
  144. package/src/shared/lib/transport/local-transport.service.ts +24 -4
  145. package/src/shared/lib/transport/remote-transport.service.ts +2 -2
  146. package/src/shared/lib/transport/request-raw-api-response.utils.ts +133 -0
  147. package/src/shared/lib/transport/transport.types.ts +8 -2
  148. package/src/shared/lib/ui-document-title/index.ts +1 -1
  149. package/tsconfig.json +1 -0
  150. package/dist/assets/api-BurjmW4A.js +0 -15
  151. package/dist/assets/app-manager-provider-DhxUmyTv.js +0 -1
  152. package/dist/assets/app-navigation.config-Bpd16Pem.js +0 -1
  153. package/dist/assets/chat-page-Cc7n80lW.js +0 -1
  154. package/dist/assets/chunk-JZWAC4HX-24FLdHl7.js +0 -3
  155. package/dist/assets/desktop-update-config-fMLlSStv.js +0 -1
  156. package/dist/assets/doc-browser-COj7x090.js +0 -1
  157. package/dist/assets/doc-browser-fyn7eDTp.js +0 -1
  158. package/dist/assets/i18n-CM4y8Mw9.js +0 -1
  159. package/dist/assets/index-CtVSzMPM.js +0 -2
  160. package/dist/assets/index-N3hjuljD.css +0 -1
  161. package/dist/assets/loader-circle-R23uEPkM.js +0 -1
  162. package/dist/assets/marketplace-page-mF-M5mku.js +0 -1
  163. package/dist/assets/mcp-marketplace-page-BArKWcRZ.js +0 -40
  164. package/dist/assets/mcp-marketplace-page-DBUcIIHJ.js +0 -1
  165. package/dist/assets/message-square-Dm34zD6k.js +0 -1
  166. package/dist/assets/play-ul4L6MWm.js +0 -1
  167. package/dist/assets/plus-D14303DH.js +0 -1
  168. package/dist/assets/remote-B4ELSd3u.js +0 -1
  169. package/dist/assets/runtime-config-page-N4FP6H0M.js +0 -1
  170. package/dist/assets/search-config-B62TY-z2.js +0 -1
  171. package/dist/assets/skeleton-BCPi52jT.js +0 -1
  172. package/dist/assets/theme-provider-WTWq_jYq.js +0 -1
  173. package/dist/assets/use-config-CyvhbRhf.js +0 -1
  174. package/dist/assets/x-tYcSDsrY.js +0 -1
  175. package/src/shared/lib/api/agents.ts +0 -34
  176. package/src/shared/lib/api/channel-auth.ts +0 -35
  177. package/src/shared/lib/api/config.ts +0 -362
  178. package/src/shared/lib/api/marketplace.ts +0 -156
  179. package/src/shared/lib/api/mcp-marketplace.ts +0 -138
  180. package/src/shared/lib/api/ncp-attachments.ts +0 -41
  181. package/src/shared/lib/api/ncp-session.ts +0 -78
  182. package/src/shared/lib/api/remote.ts +0 -86
  183. package/src/shared/lib/api/runtime-control.ts +0 -34
  184. package/src/shared/lib/api/server-path.ts +0 -46
  185. /package/dist/assets/{config-hints-CPNzbMEp.js → config-hints-MogHYQ8G.js} +0 -0
@@ -22,6 +22,7 @@ import type { ChatUiManager } from '@/features/chat/managers/chat-ui.manager';
22
22
  import type { ChatSessionListManager } from '@/features/chat/managers/chat-session-list.manager';
23
23
  import { ChatSessionPreferenceSync } from '@/features/chat/managers/chat-session-preference-sync.manager';
24
24
  import { isNcpChatSendDisabled } from '@/features/chat/utils/ncp-chat-input-availability.utils';
25
+ import { isNcpChatRuntimeBlocked } from '@/features/chat/utils/ncp-chat-runtime-availability.utils';
25
26
  import { chatRecentModelsManager } from '@/features/chat/managers/chat-recent-models.manager';
26
27
  import { chatRecentSkillsManager } from '@/features/chat/managers/chat-recent-skills.manager';
27
28
  import type { ChatModelOption } from '@/features/chat/types/chat-input.types';
@@ -67,6 +68,9 @@ export class NcpChatInputManager {
67
68
  private isSameStringArray = (left: string[], right: string[]): boolean =>
68
69
  left.length === right.length && left.every((value, index) => value === right[index]);
69
70
 
71
+ private isRuntimeBlockedForSend = (): boolean =>
72
+ isNcpChatRuntimeBlocked(systemStatusManager.getStatusView());
73
+
70
74
  private syncComposerSnapshot = (nodes: ChatComposerNode[]) => {
71
75
  const currentAttachments = useChatInputStore.getState().snapshot.attachments;
72
76
  const attachments = pruneComposerAttachments(nodes, currentAttachments);
@@ -184,7 +188,7 @@ export class NcpChatInputManager {
184
188
  isNcpChatSendDisabled({
185
189
  snapshot: inputSnapshot,
186
190
  hasSendableDraft: hasSendableContent,
187
- isRuntimeBlocked: systemStatusManager.isChatInteractionBlocked(),
191
+ isRuntimeBlocked: this.isRuntimeBlockedForSend(),
188
192
  })
189
193
  ) {
190
194
  return;
@@ -2,8 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
  import type { SessionEntryView, ThinkingLevel } from '@/shared/lib/api';
3
3
  import type { ChatModelOption } from '@/features/chat/types/chat-input.types';
4
4
  import {
5
- resolveRecentSessionPreferredModel,
6
- resolveRecentSessionPreferredThinking,
5
+ resolveRecentSessionPreferredValue,
7
6
  resolveSelectedModelValue,
8
7
  resolveSelectedThinkingLevelValue
9
8
  } from '@/features/chat/utils/chat-session-preference-governance.utils';
@@ -25,6 +24,18 @@ const modelOptions: ChatModelOption[] = [
25
24
  modelLabel: 'gpt-5',
26
25
  providerLabel: 'OpenAI',
27
26
  thinkingCapability: null
27
+ },
28
+ {
29
+ value: 'minimax/MiniMax-M2.7',
30
+ modelLabel: 'MiniMax-M2.7',
31
+ providerLabel: 'MiniMax',
32
+ thinkingCapability: null
33
+ },
34
+ {
35
+ value: 'deepseek/deepseek-v4-flash',
36
+ modelLabel: 'deepseek-v4-flash',
37
+ providerLabel: 'DeepSeek',
38
+ thinkingCapability: null
28
39
  }
29
40
  ];
30
41
 
@@ -270,10 +281,11 @@ describe('resolveRecentSessionPreferredModel', () => {
270
281
  ];
271
282
 
272
283
  expect(
273
- resolveRecentSessionPreferredModel({
284
+ resolveRecentSessionPreferredValue<string>({
274
285
  sessions,
275
286
  selectedSessionKey: 'draft',
276
- sessionType: 'codex'
287
+ sessionType: 'codex',
288
+ readPreference: (session) => session.preferredModel?.trim() || undefined
277
289
  })
278
290
  ).toBe('openai/gpt-5');
279
291
  });
@@ -300,10 +312,11 @@ describe('resolveRecentSessionPreferredModel', () => {
300
312
  ];
301
313
 
302
314
  expect(
303
- resolveRecentSessionPreferredModel({
315
+ resolveRecentSessionPreferredValue<string>({
304
316
  sessions,
305
317
  selectedSessionKey: 'codex-current',
306
- sessionType: 'codex'
318
+ sessionType: 'codex',
319
+ readPreference: (session) => session.preferredModel?.trim() || undefined
307
320
  })
308
321
  ).toBe('anthropic/claude-sonnet-4');
309
322
  });
@@ -384,10 +397,11 @@ describe('resolveRecentSessionPreferredThinking', () => {
384
397
  ];
385
398
 
386
399
  expect(
387
- resolveRecentSessionPreferredThinking({
400
+ resolveRecentSessionPreferredValue<ThinkingLevel>({
388
401
  sessions,
389
402
  selectedSessionKey: 'draft',
390
- sessionType: 'codex'
403
+ sessionType: 'codex',
404
+ readPreference: (session) => session.preferredThinking ?? undefined
391
405
  })
392
406
  ).toBe('high');
393
407
  });
@@ -32,10 +32,8 @@ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-lis
32
32
  import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
33
33
  import { useAgents } from "@/shared/hooks/use-agents";
34
34
  import { normalizeRequestedSkills } from "@/features/chat/utils/chat-runtime.utils";
35
- import {
36
- systemStatusManager,
37
- useChatRuntimeAvailability,
38
- } from "@/features/system-status";
35
+ import { useSystemStatus } from "@/features/system-status";
36
+ import { isNcpChatRuntimeBlocked, resolveNcpChatSendErrorMessage } from "@/features/chat/utils/ncp-chat-runtime-availability.utils";
39
37
  import {
40
38
  getSessionProjectName,
41
39
  normalizeSessionProjectRootValue,
@@ -126,7 +124,8 @@ function useNcpChatPageBaseState(presenter: NcpChatPresenter) {
126
124
  const currentSelectedModel = useChatInputStore(
127
125
  (state) => state.snapshot.selectedModel,
128
126
  );
129
- const runtimeAvailability = useChatRuntimeAvailability();
127
+ const systemStatus = useSystemStatus();
128
+ const isRuntimeBlocked = isNcpChatRuntimeBlocked(systemStatus);
130
129
  const agentsQuery = useAgents();
131
130
  const { confirm, ConfirmDialog } = useConfirmDialog();
132
131
  const location = useLocation();
@@ -163,7 +162,8 @@ function useNcpChatPageBaseState(presenter: NcpChatPresenter) {
163
162
  selectedAgentId,
164
163
  pendingProjectRoot,
165
164
  pendingProjectRootSessionKey,
166
- runtimeAvailability,
165
+ systemStatus,
166
+ isRuntimeBlocked,
167
167
  agentsQuery,
168
168
  confirm,
169
169
  ConfirmDialog,
@@ -186,7 +186,8 @@ function useNcpChatPageState(presenter: NcpChatPresenter) {
186
186
  agentsQuery,
187
187
  hasSessionProjectRootOverride,
188
188
  pendingProjectRoot,
189
- runtimeAvailability,
189
+ isRuntimeBlocked,
190
+ systemStatus,
190
191
  selectedAgentId,
191
192
  selectedSession,
192
193
  selectedSessionType,
@@ -204,7 +205,7 @@ function useNcpChatPageState(presenter: NcpChatPresenter) {
204
205
  const rawLastSendError =
205
206
  agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
206
207
  const filteredLastSendError =
207
- runtimeAvailability.phase === "ready" &&
208
+ systemStatus.phase === "ready" &&
208
209
  isNcpAgentStartupUnavailableErrorMessage(rawLastSendError)
209
210
  ? null
210
211
  : rawLastSendError;
@@ -231,11 +232,14 @@ function useNcpChatPageState(presenter: NcpChatPresenter) {
231
232
  canStopCurrentRun: agent.isRunning,
232
233
  stopDisabledReason: agent.isRunning ? null : "__preparing__",
233
234
  lastSendError:
234
- runtimeAvailability.isBlocked
235
+ isRuntimeBlocked
235
236
  ? null
236
- : runtimeAvailability.phase === "ready"
237
+ : systemStatus.phase === "ready"
237
238
  ? filteredLastSendError
238
- : systemStatusManager.getDisplayMessage(filteredLastSendError),
239
+ : resolveNcpChatSendErrorMessage({
240
+ message: filteredLastSendError,
241
+ status: systemStatus,
242
+ }),
239
243
  ...derivedState,
240
244
  };
241
245
  }
@@ -2,7 +2,11 @@ import { create } from 'zustand';
2
2
  import type { MutableRefObject } from 'react';
3
3
  import type { NcpMessage } from '@nextclaw/ncp';
4
4
  import type { ChatFileOperationLineViewModel } from '@nextclaw/agent-chat-ui';
5
- import type { AgentProfileView, SessionTypeIconView } from '@/shared/lib/api';
5
+ import type {
6
+ AgentProfileView,
7
+ SessionContextWindowView,
8
+ SessionTypeIconView
9
+ } from '@/shared/lib/api';
6
10
  import type { ChatModelOption } from '@/features/chat/types/chat-input.types';
7
11
 
8
12
  export type ChatChildSessionTab = {
@@ -58,6 +62,7 @@ export type ChatThreadSnapshot = {
58
62
  activeChildSessionKey?: string | null;
59
63
  workspaceFileTabs: ChatWorkspaceFileTab[];
60
64
  activeWorkspaceFileKey?: string | null;
65
+ contextWindow?: SessionContextWindowView | null;
61
66
  };
62
67
 
63
68
  type ChatThreadStore = {
@@ -93,7 +98,8 @@ const initialSnapshot: ChatThreadSnapshot = {
93
98
  childSessionTabs: [],
94
99
  activeChildSessionKey: null,
95
100
  workspaceFileTabs: [],
96
- activeWorkspaceFileKey: null
101
+ activeWorkspaceFileKey: null,
102
+ contextWindow: null
97
103
  };
98
104
 
99
105
  export const useChatThreadStore = create<ChatThreadStore>((set) => ({
@@ -0,0 +1,50 @@
1
+ import type { ChatContextWindowIndicator } from '@nextclaw/agent-chat-ui';
2
+ import type { SessionContextWindowView } from '@/shared/lib/api';
3
+ import { t } from '@/shared/lib/i18n';
4
+
5
+ function formatTokenCount(value: number): string {
6
+ if (value >= 1_000_000) {
7
+ return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
8
+ }
9
+ if (value >= 1_000) {
10
+ return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k`;
11
+ }
12
+ return String(value);
13
+ }
14
+
15
+ export function buildChatContextWindowIndicator(
16
+ contextWindow: SessionContextWindowView | null | undefined
17
+ ): ChatContextWindowIndicator | null {
18
+ if (!contextWindow || contextWindow.totalContextTokens <= 0) {
19
+ return null;
20
+ }
21
+ const ratio = contextWindow.usedContextTokens / contextWindow.totalContextTokens;
22
+ const clampedRatio = Math.max(0, Math.min(1, ratio));
23
+ const percentLabel = `${Math.round(clampedRatio * 100)}%`;
24
+ const tone: ChatContextWindowIndicator['tone'] =
25
+ clampedRatio >= 0.9 ? 'danger' : clampedRatio >= 0.75 ? 'warning' : 'neutral';
26
+ const details: ChatContextWindowIndicator['details'] = [
27
+ { label: t('chatContextWindowUsed'), value: formatTokenCount(contextWindow.usedContextTokens) },
28
+ { label: t('chatContextWindowTotal'), value: formatTokenCount(contextWindow.totalContextTokens) },
29
+ { label: t('chatContextWindowAvailable'), value: formatTokenCount(contextWindow.availableContextTokens) }
30
+ ];
31
+ if (contextWindow.prunedUsedContextTokens !== contextWindow.usedContextTokens) {
32
+ details.push({
33
+ label: t('chatContextWindowPruned'),
34
+ value: formatTokenCount(contextWindow.prunedUsedContextTokens)
35
+ });
36
+ }
37
+ if (contextWindow.droppedHistoryCount > 0) {
38
+ details.push({ label: t('chatContextWindowDroppedHistory'), value: String(contextWindow.droppedHistoryCount) });
39
+ }
40
+ if (contextWindow.truncatedToolResultCount > 0) {
41
+ details.push({ label: t('chatContextWindowTruncatedTools'), value: String(contextWindow.truncatedToolResultCount) });
42
+ }
43
+ return {
44
+ label: t('chatContextWindow'),
45
+ percentLabel,
46
+ ratio: clampedRatio,
47
+ tone,
48
+ details
49
+ };
50
+ }
@@ -179,7 +179,7 @@ class HistoryMessageBuilder {
179
179
  };
180
180
 
181
181
  private appendAssistantMessage = (message: SessionMessageView): void => {
182
- const timestamp = message.timestamp;
182
+ const { timestamp } = message;
183
183
  const text = extractMessageText(message.content).trim();
184
184
  if (text) {
185
185
  this.appendAssistantText(timestamp, text);
@@ -0,0 +1,114 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { ChatModelOption } from '@/features/chat/types/chat-input.types';
5
+ import { useSyncSelectedModel } from '@/features/chat/utils/chat-session-preference-governance.utils';
6
+
7
+ const modelOptions: ChatModelOption[] = [
8
+ {
9
+ value: 'minimax/MiniMax-M2.7',
10
+ modelLabel: 'MiniMax-M2.7',
11
+ providerLabel: 'MiniMax',
12
+ thinkingCapability: null
13
+ },
14
+ {
15
+ value: 'deepseek/deepseek-v4-flash',
16
+ modelLabel: 'deepseek-v4-flash',
17
+ providerLabel: 'DeepSeek',
18
+ thinkingCapability: null
19
+ },
20
+ {
21
+ value: 'openai/gpt-5',
22
+ modelLabel: 'gpt-5',
23
+ providerLabel: 'OpenAI',
24
+ thinkingCapability: null
25
+ }
26
+ ];
27
+
28
+ type HookProps = {
29
+ fallbackPreferredModel?: string;
30
+ defaultModel: string;
31
+ };
32
+
33
+ describe('useSyncSelectedModel', () => {
34
+ it('replaces an auto-selected global default with the later-arriving recent same-runtime model for a fresh draft session', async () => {
35
+ const initialProps: HookProps = {
36
+ fallbackPreferredModel: undefined,
37
+ defaultModel: 'minimax/MiniMax-M2.7'
38
+ };
39
+ const { result, rerender } = renderHook(
40
+ (props: HookProps) => {
41
+ const [selectedModel, setSelectedModel] = useState('');
42
+ useSyncSelectedModel({
43
+ modelOptions,
44
+ selectedSessionKey: 'draft-session',
45
+ selectedSessionExists: false,
46
+ fallbackPreferredModel: props.fallbackPreferredModel,
47
+ defaultModel: props.defaultModel,
48
+ setSelectedModel
49
+ });
50
+ return selectedModel;
51
+ },
52
+ {
53
+ initialProps
54
+ }
55
+ );
56
+
57
+ await waitFor(() => {
58
+ expect(result.current).toBe('minimax/MiniMax-M2.7');
59
+ });
60
+
61
+ rerender({
62
+ fallbackPreferredModel: 'deepseek/deepseek-v4-flash',
63
+ defaultModel: 'minimax/MiniMax-M2.7'
64
+ });
65
+
66
+ await waitFor(() => {
67
+ expect(result.current).toBe('deepseek/deepseek-v4-flash');
68
+ });
69
+ });
70
+
71
+ it('does not override a manual model selection when recent same-runtime model data arrives later', async () => {
72
+ const initialProps: HookProps = {
73
+ fallbackPreferredModel: undefined,
74
+ defaultModel: 'minimax/MiniMax-M2.7'
75
+ };
76
+ const { result, rerender } = renderHook(
77
+ (props: HookProps) => {
78
+ const [selectedModel, setSelectedModel] = useState('');
79
+ useSyncSelectedModel({
80
+ modelOptions,
81
+ selectedSessionKey: 'draft-session',
82
+ selectedSessionExists: false,
83
+ fallbackPreferredModel: props.fallbackPreferredModel,
84
+ defaultModel: props.defaultModel,
85
+ setSelectedModel
86
+ });
87
+ return {
88
+ selectedModel,
89
+ setSelectedModel
90
+ };
91
+ },
92
+ {
93
+ initialProps
94
+ }
95
+ );
96
+
97
+ await waitFor(() => {
98
+ expect(result.current.selectedModel).toBe('minimax/MiniMax-M2.7');
99
+ });
100
+
101
+ act(() => {
102
+ result.current.setSelectedModel('openai/gpt-5');
103
+ });
104
+
105
+ rerender({
106
+ fallbackPreferredModel: 'deepseek/deepseek-v4-flash',
107
+ defaultModel: 'minimax/MiniMax-M2.7'
108
+ });
109
+
110
+ await waitFor(() => {
111
+ expect(result.current.selectedModel).toBe('openai/gpt-5');
112
+ });
113
+ });
114
+ });
@@ -160,37 +160,14 @@ export function resolveRecentSessionPreferredValue<T>(params: {
160
160
  return bestValue;
161
161
  }
162
162
 
163
- export function resolveRecentSessionPreferredModel(params: {
164
- sessions: readonly SessionEntryView[];
165
- selectedSessionKey?: string | null;
166
- sessionType?: string | null;
167
- }): string | undefined {
168
- const { sessions, selectedSessionKey, sessionType } = params;
169
- return resolveRecentSessionPreferredValue<string>({
170
- sessions,
171
- selectedSessionKey,
172
- sessionType,
173
- readPreference: (session) => session.preferredModel?.trim() || undefined
174
- });
175
- }
176
-
177
- export function resolveRecentSessionPreferredThinking(params: {
178
- sessions: readonly SessionEntryView[];
179
- selectedSessionKey?: string | null;
180
- sessionType?: string | null;
181
- }): ThinkingLevel | undefined {
182
- const { sessions, selectedSessionKey, sessionType } = params;
183
- return resolveRecentSessionPreferredValue<ThinkingLevel>({
184
- sessions,
185
- selectedSessionKey,
186
- sessionType,
187
- readPreference: (session) => session.preferredThinking ?? undefined
188
- });
163
+ function buildSyncKey(parts: unknown[]): string {
164
+ return parts.map((part) => (part == null ? '' : String(part))).join('\u0002');
189
165
  }
190
166
 
191
167
  type UseSyncSessionPreferenceParams<T> = {
192
168
  isPreferenceAvailable: boolean;
193
169
  emptyValue: T;
170
+ syncKey: string;
194
171
  selectedSessionKey?: string | null;
195
172
  selectedSessionExists?: boolean;
196
173
  setValue: Dispatch<SetStateAction<T>>;
@@ -201,6 +178,7 @@ function useSyncSessionPreference<T>(params: UseSyncSessionPreferenceParams<T>)
201
178
  const {
202
179
  isPreferenceAvailable,
203
180
  emptyValue,
181
+ syncKey,
204
182
  selectedSessionKey,
205
183
  selectedSessionExists = false,
206
184
  setValue,
@@ -208,27 +186,31 @@ function useSyncSessionPreference<T>(params: UseSyncSessionPreferenceParams<T>)
208
186
  } = params;
209
187
  const previousSessionKeyRef = useRef<string | null | undefined>(undefined);
210
188
  const resolveValueRef = useRef(resolveValue);
211
-
212
- useEffect(() => {
213
- resolveValueRef.current = resolveValue;
214
- }, [resolveValue]);
189
+ const lastSyncedValueRef = useRef<T>(emptyValue);
190
+ resolveValueRef.current = resolveValue;
215
191
 
216
192
  useEffect(() => {
217
193
  const sessionChanged = previousSessionKeyRef.current !== selectedSessionKey;
218
194
  if (!isPreferenceAvailable) {
219
195
  setValue(emptyValue);
196
+ lastSyncedValueRef.current = emptyValue;
220
197
  previousSessionKeyRef.current = selectedSessionKey;
221
198
  return;
222
199
  }
223
- setValue((prev) =>
224
- resolveValueRef.current({
225
- currentValue: prev,
200
+ setValue((prev) => {
201
+ const next = resolveValueRef.current({
202
+ currentValue:
203
+ !sessionChanged && Object.is(prev, lastSyncedValueRef.current)
204
+ ? emptyValue
205
+ : prev,
226
206
  sessionChanged,
227
207
  preserveCurrentValueOnSessionChange: sessionChanged && Boolean(selectedSessionKey) && !selectedSessionExists
228
- })
229
- );
208
+ });
209
+ lastSyncedValueRef.current = next;
210
+ return next;
211
+ });
230
212
  previousSessionKeyRef.current = selectedSessionKey;
231
- }, [emptyValue, isPreferenceAvailable, selectedSessionExists, selectedSessionKey, setValue]);
213
+ }, [emptyValue, isPreferenceAvailable, selectedSessionExists, selectedSessionKey, setValue, syncKey]);
232
214
  }
233
215
 
234
216
  export function useSyncSelectedModel(params: {
@@ -244,6 +226,12 @@ export function useSyncSelectedModel(params: {
244
226
  useSyncSessionPreference<string>({
245
227
  isPreferenceAvailable: modelOptions.length > 0,
246
228
  emptyValue: '',
229
+ syncKey: buildSyncKey([
230
+ modelOptions.map((option) => option.value).join('\u0001'),
231
+ selectedSessionPreferredModel,
232
+ fallbackPreferredModel,
233
+ defaultModel
234
+ ]),
247
235
  selectedSessionKey,
248
236
  selectedSessionExists,
249
237
  setValue: setSelectedModel,
@@ -273,6 +261,12 @@ export function useSyncSelectedThinking(params: {
273
261
  useSyncSessionPreference<ThinkingLevel | null>({
274
262
  isPreferenceAvailable: supportedThinkingLevels.length > 0,
275
263
  emptyValue: null,
264
+ syncKey: buildSyncKey([
265
+ supportedThinkingLevels.join('\u0001'),
266
+ selectedSessionPreferredThinking,
267
+ fallbackPreferredThinking,
268
+ defaultThinkingLevel
269
+ ]),
276
270
  selectedSessionKey,
277
271
  selectedSessionExists,
278
272
  setValue: setSelectedThinkingLevel,
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { t } from '@/shared/lib/i18n';
3
+ import type { SystemStatusView } from '@/features/system-status';
4
+ import {
5
+ isNcpChatRuntimeBlocked,
6
+ resolveNcpChatRuntimeMessage,
7
+ resolveNcpChatSendErrorMessage,
8
+ } from './ncp-chat-runtime-availability.utils';
9
+
10
+ function createStatus(overrides: Partial<SystemStatusView> = {}): SystemStatusView {
11
+ return {
12
+ lifecyclePhase: 'ready',
13
+ phase: 'ready',
14
+ connectionStatus: 'connected',
15
+ hasReachedReady: true,
16
+ lastReadyAt: Date.now(),
17
+ recoveryStartedAt: null,
18
+ bootstrapStatus: {
19
+ phase: 'ready',
20
+ ncpAgent: {
21
+ state: 'ready',
22
+ },
23
+ pluginHydration: {
24
+ state: 'ready',
25
+ loadedPluginCount: 1,
26
+ totalPluginCount: 1,
27
+ },
28
+ channels: {
29
+ state: 'ready',
30
+ enabled: [],
31
+ },
32
+ remote: {
33
+ state: 'pending',
34
+ },
35
+ },
36
+ lastError: null,
37
+ lastTransportError: null,
38
+ runtimeControlView: null,
39
+ runtimeControlError: null,
40
+ activeSystemAction: null,
41
+ lastSystemActionError: null,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ describe('ncp-chat-runtime-availability.utils', () => {
47
+ it('allows chat send when the NCP agent is ready even if the aggregate phase is stalled', () => {
48
+ expect(isNcpChatRuntimeBlocked(createStatus())).toBe(false);
49
+ expect(
50
+ isNcpChatRuntimeBlocked(
51
+ createStatus({
52
+ lifecyclePhase: 'stalled',
53
+ phase: 'stalled',
54
+ connectionStatus: 'disconnected',
55
+ recoveryStartedAt: Date.now(),
56
+ })
57
+ )
58
+ ).toBe(false);
59
+ });
60
+
61
+ it('blocks chat send while the NCP agent is not ready', () => {
62
+ expect(
63
+ isNcpChatRuntimeBlocked(
64
+ createStatus({
65
+ bootstrapStatus: null,
66
+ })
67
+ )
68
+ ).toBe(true);
69
+ });
70
+
71
+ it('uses the startup message during cold start', () => {
72
+ expect(
73
+ resolveNcpChatRuntimeMessage(
74
+ createStatus({
75
+ lifecyclePhase: 'cold-starting',
76
+ phase: 'cold-starting',
77
+ connectionStatus: 'connecting',
78
+ hasReachedReady: false,
79
+ lastReadyAt: null,
80
+ })
81
+ )
82
+ ).toBe(t('chatRuntimeInitializing'));
83
+ });
84
+
85
+ it('uses the bootstrap error when startup failed', () => {
86
+ expect(
87
+ resolveNcpChatRuntimeMessage(
88
+ createStatus({
89
+ lifecyclePhase: 'startup-failed',
90
+ phase: 'startup-failed',
91
+ connectionStatus: 'disconnected',
92
+ hasReachedReady: false,
93
+ lastReadyAt: null,
94
+ bootstrapStatus: {
95
+ phase: 'error',
96
+ ncpAgent: {
97
+ state: 'error',
98
+ error: 'boom',
99
+ },
100
+ pluginHydration: {
101
+ state: 'pending',
102
+ loadedPluginCount: 0,
103
+ totalPluginCount: 0,
104
+ },
105
+ channels: {
106
+ state: 'pending',
107
+ enabled: [],
108
+ },
109
+ remote: {
110
+ state: 'pending',
111
+ },
112
+ lastError: 'boom',
113
+ },
114
+ })
115
+ )
116
+ ).toBe('boom');
117
+ });
118
+
119
+ it('prefers the centralized action message while a system action is running', () => {
120
+ expect(
121
+ resolveNcpChatRuntimeMessage(
122
+ createStatus({
123
+ activeSystemAction: {
124
+ action: 'restart-service',
125
+ lifecycle: 'recovering',
126
+ serviceState: null,
127
+ message: 'NextClaw 正在恢复连接',
128
+ },
129
+ })
130
+ )
131
+ ).toBe('NextClaw 正在恢复连接');
132
+ });
133
+
134
+ it('maps transient chat errors to friendly recovery copy while recovering', () => {
135
+ expect(
136
+ resolveNcpChatSendErrorMessage({
137
+ message: 'NCP fetch failed for POST /api/ncp/agent: Error: Failed to fetch',
138
+ status: createStatus({
139
+ lifecyclePhase: 'recovering',
140
+ phase: 'recovering',
141
+ connectionStatus: 'connecting',
142
+ recoveryStartedAt: Date.now(),
143
+ lastError: 'Failed to fetch',
144
+ lastTransportError: 'Failed to fetch',
145
+ }),
146
+ })
147
+ ).toBe(t('runtimeControlRecoveringHelp'));
148
+ });
149
+
150
+ it('suppresses transient transport errors after recovery stalls', () => {
151
+ expect(
152
+ resolveNcpChatSendErrorMessage({
153
+ message: 'NCP fetch failed for POST /api/ncp/agent: Error: Failed to fetch',
154
+ status: createStatus({
155
+ lifecyclePhase: 'stalled',
156
+ phase: 'stalled',
157
+ connectionStatus: 'disconnected',
158
+ recoveryStartedAt: Date.now(),
159
+ lastError: 'Failed to fetch',
160
+ lastTransportError: 'Failed to fetch',
161
+ }),
162
+ })
163
+ ).toBeNull();
164
+ });
165
+ });