@nextclaw/ui 0.12.22 → 0.12.24

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 (117) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/assets/{api-lwyw9j7i.js → api-D2xRKmZd.js} +5 -5
  3. package/dist/assets/app-manager-provider-CNaZboG4.js +1 -0
  4. package/dist/assets/{app-navigation.config-DgiR0c5_.js → app-navigation.config-Ihhrrt--.js} +1 -1
  5. package/dist/assets/{book-open-DgLqYpNY.js → book-open-DDlN5MvX.js} +1 -1
  6. package/dist/assets/{channels-list-page-Dl839n02.js → channels-list-page-p26lgxLk.js} +2 -2
  7. package/dist/assets/{chat-DwUf7AKR.js → chat-Dkh2qtuz.js} +8 -8
  8. package/dist/assets/{chat-page-B-FvPmA7.js → chat-page-DoTmE2wx.js} +1 -1
  9. package/dist/assets/{chunk-JZWAC4HX-u4uYphxM.js → chunk-JZWAC4HX-Kydj4yEz.js} +1 -1
  10. package/dist/assets/{config-split-page-BMRGuCJQ.js → config-split-page-DIOCjj2Q.js} +1 -1
  11. package/dist/assets/{createLucideIcon-BZkY6emz.js → createLucideIcon-BLMK3QUd.js} +1 -1
  12. package/dist/assets/{desktop-update-config-D5g_gPak.js → desktop-update-config-DlpzDfKM.js} +1 -1
  13. package/dist/assets/{dialog-CdtCU2xX.js → dialog-C3D7Be0p.js} +1 -1
  14. package/dist/assets/{dist-CuqvE--P.js → dist-CPlbUgwU.js} +1 -1
  15. package/dist/assets/{doc-browser-BUlCkZo2.js → doc-browser-C8FM5fC0.js} +1 -1
  16. package/dist/assets/doc-browser-RJUOL_GO.js +1 -0
  17. package/dist/assets/{doc-browser-context-DfLHAWbG.js → doc-browser-context-BJuMaI3o.js} +1 -1
  18. package/dist/assets/{doc-browser-CzCV73NJ.js → doc-browser-p82AdNO-.js} +1 -1
  19. package/dist/assets/{es2015-yYU5Ad5w.js → es2015-xqN1slyW.js} +1 -1
  20. package/dist/assets/{external-link-Sw3ah_JD.js → external-link-DwfSfTLB.js} +1 -1
  21. package/dist/assets/{folder-D7-VTnkz.js → folder-CeJKPx5P.js} +1 -1
  22. package/dist/assets/{hash-zajSTDXZ.js → hash-BqxRTZW5.js} +1 -1
  23. package/dist/assets/i18n-DnTGDIRw.js +1 -0
  24. package/dist/assets/{index-Doxyk7L2.js → index-pBvbJ5Mt.js} +2 -2
  25. package/dist/assets/{key-round-CnI1mc9F.js → key-round-CJ5gDAAG.js} +1 -1
  26. package/dist/assets/loader-circle-fd-vQKtW.js +1 -0
  27. package/dist/assets/{logo-badge-BQgKnVtz.js → logo-badge-KAe-7d8c.js} +1 -1
  28. package/dist/assets/{logos-CqVm0q0W.js → logos-C4sYP1Vl.js} +1 -1
  29. package/dist/assets/marketplace-page-Cql0kDi-.js +1 -0
  30. package/dist/assets/{marketplace-page-CawcdL6Y.js → marketplace-page-m4P5g_Ht.js} +1 -1
  31. package/dist/assets/mcp-marketplace-page-9WVKl1m1.js +1 -0
  32. package/dist/assets/{mcp-marketplace-page-DEGfJ_70.js → mcp-marketplace-page-ByzBQZcx.js} +1 -1
  33. package/dist/assets/message-square-z_osm9c0.js +1 -0
  34. package/dist/assets/{model-config-r-1RPSrZ.js → model-config-Dbr_0APb.js} +1 -1
  35. package/dist/assets/{notice-card-BPtCVEKW.js → notice-card-BFDbKQDA.js} +1 -1
  36. package/dist/assets/play-Dv6Nr1Ew.js +1 -0
  37. package/dist/assets/plus-D8eKFY7h.js +1 -0
  38. package/dist/assets/{popover-jbfQhYQh.js → popover-B86Dbfhf.js} +1 -1
  39. package/dist/assets/{provider-scoped-model-input-gdk2lmRi.js → provider-scoped-model-input-DFm6N2f7.js} +1 -1
  40. package/dist/assets/{providers-list-DpISIr3M.js → providers-list-BJcLOjun.js} +1 -1
  41. package/dist/assets/{refresh-ccw-Bii4w8aB.js → refresh-ccw-ByVwmnN_.js} +1 -1
  42. package/dist/assets/{refresh-cw-BxojR62w.js → refresh-cw-PcqoYB3K.js} +1 -1
  43. package/dist/assets/remote-BOxo9iwd.js +1 -0
  44. package/dist/assets/{rotate-cw-1Xqa7LZ8.js → rotate-cw-BZ2JObNs.js} +1 -1
  45. package/dist/assets/runtime-config-page-CjLhnbSl.js +1 -0
  46. package/dist/assets/{save--BVI5wZX.js → save-euRxl8pI.js} +1 -1
  47. package/dist/assets/{search-vChioOoe.js → search-CLd7m0M7.js} +1 -1
  48. package/dist/assets/{search-config-BWqz8nqY.js → search-config-J4Htco-P.js} +1 -1
  49. package/dist/assets/{secrets-config-CjzSNg0Y.js → secrets-config-CUdERjco.js} +1 -1
  50. package/dist/assets/{select-Cw5Zkb1w.js → select-CJ0wbo3D.js} +1 -1
  51. package/dist/assets/{sessions-config-page-beoDPtII.js → sessions-config-page-DpK991fs.js} +2 -2
  52. package/dist/assets/{setting-row-Cjl2d40s.js → setting-row-D1Yygqp7.js} +1 -1
  53. package/dist/assets/{settings-CiRChctQ.js → settings-drbWqzA4.js} +1 -1
  54. package/dist/assets/skeleton-BK1SOSRA.js +1 -0
  55. package/dist/assets/{sparkles-D1ZKWdm4.js → sparkles-DVfeSVJQ.js} +1 -1
  56. package/dist/assets/{status-dot-Dv_hiUVa.js → status-dot-ChvPCib9.js} +1 -1
  57. package/dist/assets/{tabs-custom-CsACkVji.js → tabs-custom-Hia_ong0.js} +1 -1
  58. package/dist/assets/{tag-chip-CoWHxYJj.js → tag-chip-FrkmkT8r.js} +1 -1
  59. package/dist/assets/theme-provider-0hxjiPc_.js +2 -0
  60. package/dist/assets/{tooltip-GYzH-Hfq.js → tooltip-Cj4yA0gH.js} +1 -1
  61. package/dist/assets/{trash-2-rY9ZteZX.js → trash-2-CBsHCfqq.js} +1 -1
  62. package/dist/assets/{use-config-BhJHD3-G.js → use-config-38Ur-89i.js} +1 -1
  63. package/dist/assets/{use-confirm-dialog-Bqgy3Gi-.js → use-confirm-dialog-DPQThaeU.js} +1 -1
  64. package/dist/assets/{use-infinite-scroll-loader-BfexitoF.js → use-infinite-scroll-loader-5Gf1xQi7.js} +1 -1
  65. package/dist/assets/{use-viewport-layout-D33zVbr5.js → use-viewport-layout-D1XzKeip.js} +1 -1
  66. package/dist/assets/x-CM-XDMpk.js +1 -0
  67. package/dist/index.html +39 -39
  68. package/package.json +9 -9
  69. package/src/features/account/hooks/use-auth.test.ts +7 -5
  70. package/src/features/account/hooks/use-auth.ts +23 -20
  71. package/src/features/chat/components/chat-sidebar-session-item.tsx +1 -1
  72. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +2 -2
  73. package/src/features/chat/components/layout/chat-sidebar.test.tsx +74 -0
  74. package/src/features/chat/components/layout/chat-sidebar.tsx +28 -29
  75. package/src/features/chat/hooks/use-hydrated-ncp-agent.test.tsx +6 -0
  76. package/src/features/chat/hooks/use-ncp-agent-runtime.test.tsx +158 -69
  77. package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +2 -2
  78. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +7 -7
  79. package/src/features/chat/hooks/use-ncp-session-conversation.test.tsx +10 -0
  80. package/src/features/chat/hooks/use-ncp-session-conversation.ts +2 -1
  81. package/src/features/chat/hooks/use-selected-session-context-window-indicator.ts +2 -4
  82. package/src/features/chat/managers/chat-session-list.manager.test.ts +19 -16
  83. package/src/features/chat/managers/chat-session-list.manager.ts +20 -24
  84. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +23 -12
  85. package/src/features/chat/managers/ncp-chat-input.manager.ts +4 -2
  86. package/src/features/chat/pages/ncp-chat-page.tsx +23 -13
  87. package/src/features/chat/stores/chat-session-list.store.ts +2 -3
  88. package/src/features/chat/types/chat-stream.types.ts +1 -1
  89. package/src/features/chat/utils/ncp-session-adapter.utils.ts +1 -1
  90. package/src/features/system-status/hooks/use-system-status.ts +6 -28
  91. package/src/features/system-status/index.ts +2 -1
  92. package/src/features/system-status/managers/system-status.manager.bootstrap-polling.test.ts +14 -4
  93. package/src/features/system-status/managers/system-status.manager.test.ts +2 -8
  94. package/src/features/system-status/managers/system-status.manager.ts +20 -30
  95. package/src/shared/components/common/brand-header.test.tsx +84 -3
  96. package/src/shared/components/common/brand-header.tsx +37 -39
  97. package/src/shared/lib/api/managers/client.manager.ts +30 -2
  98. package/src/shared/lib/api/ncp-session-query-cache.test.ts +26 -1
  99. package/src/shared/lib/api/ncp-session-query-cache.ts +5 -1
  100. package/src/shared/lib/api/utils/config.utils.ts +6 -4
  101. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +3 -1
  102. package/src/shared/lib/transport/index.ts +1 -0
  103. package/src/shared/lib/transport/transport.types.ts +20 -0
  104. package/dist/assets/app-manager-provider-C0ONQxUg.js +0 -1
  105. package/dist/assets/doc-browser-Doh2541x.js +0 -1
  106. package/dist/assets/i18n-C5Mibli1.js +0 -1
  107. package/dist/assets/loader-circle-B5i8oMMY.js +0 -1
  108. package/dist/assets/marketplace-page-BRHkZaO5.js +0 -1
  109. package/dist/assets/mcp-marketplace-page-CL7BF4dD.js +0 -1
  110. package/dist/assets/message-square-D6Z4NwpG.js +0 -1
  111. package/dist/assets/play-D8WJLnJe.js +0 -1
  112. package/dist/assets/plus-Di0KAkiO.js +0 -1
  113. package/dist/assets/remote-BnRNqMlb.js +0 -1
  114. package/dist/assets/runtime-config-page-DQ8YY8Lc.js +0 -1
  115. package/dist/assets/skeleton-CFQRIUzt.js +0 -1
  116. package/dist/assets/theme-provider-B5XReW_-.js +0 -1
  117. package/dist/assets/x-DpTzXQcX.js +0 -1
@@ -77,7 +77,7 @@ describe('NcpChatInputManager', () => {
77
77
  } as unknown as ConstructorParameters<typeof NcpChatInputManager>[1];
78
78
  const sessionListManager = {
79
79
  ensureDraftSession: vi.fn(() => 'draft-session'),
80
- promoteRootDraftSessionRoute: vi.fn(),
80
+ materializeRootSessionRoute: vi.fn(),
81
81
  } as unknown as ConstructorParameters<typeof NcpChatInputManager>[2];
82
82
  const manager = new NcpChatInputManager(
83
83
  {} as ConstructorParameters<typeof NcpChatInputManager>[0],
@@ -95,14 +95,21 @@ describe('NcpChatInputManager', () => {
95
95
  }),
96
96
  );
97
97
  expect(sessionListManager.ensureDraftSession).not.toHaveBeenCalled();
98
- expect(sessionListManager.promoteRootDraftSessionRoute).toHaveBeenCalledWith('current-route-session');
98
+ expect(sessionListManager.materializeRootSessionRoute).not.toHaveBeenCalled();
99
99
  });
100
100
 
101
- it('keeps sending through the current root draft session while /chat is still in blank-draft mode', async () => {
101
+ it('sends without a session key while /chat is still in blank-draft mode', async () => {
102
102
  useChatThreadStore.setState({
103
103
  snapshot: {
104
104
  ...useChatThreadStore.getState().snapshot,
105
- sessionKey: 'draft-root-session',
105
+ sessionKey: null,
106
+ },
107
+ });
108
+ useChatSessionListStore.setState({
109
+ snapshot: {
110
+ ...useChatSessionListStore.getState().snapshot,
111
+ selectedSessionKey: null,
112
+ draftSessionKey: null,
106
113
  },
107
114
  });
108
115
  const streamActionsManager = {
@@ -111,7 +118,7 @@ describe('NcpChatInputManager', () => {
111
118
  } as unknown as ConstructorParameters<typeof NcpChatInputManager>[1];
112
119
  const sessionListManager = {
113
120
  ensureDraftSession: vi.fn(() => 'materialized-draft-session'),
114
- promoteRootDraftSessionRoute: vi.fn(),
121
+ materializeRootSessionRoute: vi.fn(),
115
122
  } as unknown as ConstructorParameters<typeof NcpChatInputManager>[2];
116
123
  const manager = new NcpChatInputManager(
117
124
  {} as ConstructorParameters<typeof NcpChatInputManager>[0],
@@ -121,14 +128,18 @@ describe('NcpChatInputManager', () => {
121
128
 
122
129
  await manager.send();
123
130
 
124
- expect(sessionListManager.ensureDraftSession).not.toHaveBeenCalled();
131
+ expect(sessionListManager.ensureDraftSession).toHaveBeenCalledWith('native');
132
+ expect(streamActionsManager.sendMessage).toHaveBeenCalledWith(
133
+ expect.not.objectContaining({
134
+ sessionKey: expect.any(String),
135
+ }),
136
+ );
125
137
  expect(streamActionsManager.sendMessage).toHaveBeenCalledWith(
126
138
  expect.objectContaining({
127
- sessionKey: 'draft-root-session',
128
139
  message: 'hello from current thread',
129
140
  }),
130
141
  );
131
- expect(sessionListManager.promoteRootDraftSessionRoute).toHaveBeenCalledWith('draft-root-session');
142
+ expect(sessionListManager.materializeRootSessionRoute).not.toHaveBeenCalled();
132
143
  });
133
144
 
134
145
  it('does not send while the runtime is still blocked during startup', async () => {
@@ -158,7 +169,7 @@ describe('NcpChatInputManager', () => {
158
169
  } as unknown as ConstructorParameters<typeof NcpChatInputManager>[1];
159
170
  const sessionListManager = {
160
171
  ensureDraftSession: vi.fn(() => 'draft-session'),
161
- promoteRootDraftSessionRoute: vi.fn(),
172
+ materializeRootSessionRoute: vi.fn(),
162
173
  } as unknown as ConstructorParameters<typeof NcpChatInputManager>[2];
163
174
  const manager = new NcpChatInputManager(
164
175
  {} as ConstructorParameters<typeof NcpChatInputManager>[0],
@@ -169,7 +180,7 @@ describe('NcpChatInputManager', () => {
169
180
  await manager.send();
170
181
 
171
182
  expect(streamActionsManager.sendMessage).not.toHaveBeenCalled();
172
- expect(sessionListManager.promoteRootDraftSessionRoute).not.toHaveBeenCalled();
183
+ expect(sessionListManager.materializeRootSessionRoute).not.toHaveBeenCalled();
173
184
  });
174
185
 
175
186
  it('still attempts to send when provider metadata is stale or the session type is marked unavailable', async () => {
@@ -187,7 +198,7 @@ describe('NcpChatInputManager', () => {
187
198
  } as unknown as ConstructorParameters<typeof NcpChatInputManager>[1];
188
199
  const sessionListManager = {
189
200
  ensureDraftSession: vi.fn(() => 'draft-session'),
190
- promoteRootDraftSessionRoute: vi.fn(),
201
+ materializeRootSessionRoute: vi.fn(),
191
202
  } as unknown as ConstructorParameters<typeof NcpChatInputManager>[2];
192
203
  const manager = new NcpChatInputManager(
193
204
  {} as ConstructorParameters<typeof NcpChatInputManager>[0],
@@ -198,6 +209,6 @@ describe('NcpChatInputManager', () => {
198
209
  await manager.send();
199
210
 
200
211
  expect(streamActionsManager.sendMessage).toHaveBeenCalledTimes(1);
201
- expect(sessionListManager.promoteRootDraftSessionRoute).toHaveBeenCalledWith('current-route-session');
212
+ expect(sessionListManager.materializeRootSessionRoute).not.toHaveBeenCalled();
202
213
  });
203
214
  });
@@ -197,11 +197,14 @@ export class NcpChatInputManager {
197
197
  const sessionKey =
198
198
  threadSnapshot.sessionKey ??
199
199
  sessionSnapshot.selectedSessionKey ??
200
+ null;
201
+ if (!sessionKey && inputSnapshot.selectedSessionType?.trim()) {
200
202
  this.sessionListManager.ensureDraftSession(inputSnapshot.selectedSessionType);
203
+ }
201
204
  this.setComposerNodes(createInitialChatComposerNodes());
202
205
  await this.streamActionsManager.sendMessage({
203
206
  message,
204
- sessionKey,
207
+ ...(sessionKey ? { sessionKey } : {}),
205
208
  agentId: sessionSnapshot.selectedAgentId,
206
209
  sessionType: inputSnapshot.selectedSessionType,
207
210
  model: inputSnapshot.selectedModel || undefined,
@@ -213,7 +216,6 @@ export class NcpChatInputManager {
213
216
  restoreDraftOnError: true,
214
217
  composerNodes
215
218
  });
216
- this.sessionListManager.promoteRootDraftSessionRoute(sessionKey);
217
219
  };
218
220
 
219
221
  stop = async () => {
@@ -106,9 +106,6 @@ function useNcpChatPageBaseState(presenter: NcpChatPresenter) {
106
106
  const selectedSessionKey = useChatSessionListStore(
107
107
  (state) => state.snapshot.selectedSessionKey,
108
108
  );
109
- const draftSessionKey = useChatSessionListStore(
110
- (state) => state.snapshot.draftSessionKey,
111
- );
112
109
  const selectedAgentId = useChatSessionListStore(
113
110
  (state) => state.snapshot.selectedAgentId,
114
111
  );
@@ -137,16 +134,16 @@ function useNcpChatPageBaseState(presenter: NcpChatPresenter) {
137
134
  () => parseSessionKeyFromRoute(routeSessionIdParam),
138
135
  [routeSessionIdParam],
139
136
  );
140
- const sessionKey = routeSessionKey ?? selectedSessionKey ?? draftSessionKey;
137
+ const sessionKey = routeSessionKey ?? undefined;
141
138
  const hasSessionProjectRootOverride =
142
139
  pendingProjectRoot !== null &&
143
- pendingProjectRootSessionKey === sessionKey;
140
+ (!sessionKey || pendingProjectRootSessionKey === sessionKey);
144
141
  const sessionProjectRootOverride = hasSessionProjectRootOverride
145
142
  ? pendingProjectRoot
146
143
  : undefined;
147
144
  const pageData = useNcpChatPageData({
148
145
  query,
149
- sessionKey,
146
+ sessionKey: sessionKey ?? null,
150
147
  projectRootOverride: sessionProjectRootOverride,
151
148
  currentSelectedModel,
152
149
  pendingSessionType,
@@ -213,7 +210,7 @@ function useNcpChatPageState(presenter: NcpChatPresenter) {
213
210
  ? (agentsQuery.data?.agents ?? [])
214
211
  : [{ id: selectedSession?.agentId ?? selectedAgentId }];
215
212
  const derivedState = useNcpChatDerivedState({
216
- sessionKey,
213
+ sessionKey: sessionKey ?? null,
217
214
  selectedSession,
218
215
  selectedAgentId,
219
216
  availableAgents,
@@ -251,14 +248,13 @@ function useNcpChatStreamBindings(params: ReturnType<typeof useNcpChatPageState>
251
248
  pendingProjectRootSessionKey,
252
249
  presenter,
253
250
  selectedSession,
254
- selectedSessionKey,
255
251
  selectedSessionKeyRef,
256
252
  sessionKey,
257
253
  } = params;
258
254
  useEffect(() => {
259
255
  presenter.chatStreamActionsManager.bind({
260
256
  sendMessage: async (payload) => {
261
- if (payload.sessionKey !== sessionKey) {
257
+ if ((payload.sessionKey ?? null) !== (sessionKey ?? null)) {
262
258
  return;
263
259
  }
264
260
  const metadata = buildNcpSendMetadata({
@@ -267,7 +263,7 @@ function useNcpChatStreamBindings(params: ReturnType<typeof useNcpChatPageState>
267
263
  thinkingLevel: payload.thinkingLevel,
268
264
  sessionType: payload.sessionType,
269
265
  projectRoot:
270
- payload.sessionKey === pendingProjectRootSessionKey
266
+ !payload.sessionKey || payload.sessionKey === pendingProjectRootSessionKey
271
267
  ? pendingProjectRoot
272
268
  : (selectedSession?.projectRoot ?? null),
273
269
  requestedSkills: payload.requestedSkills,
@@ -322,7 +318,6 @@ function useNcpChatStreamBindings(params: ReturnType<typeof useNcpChatPageState>
322
318
  pendingProjectRoot,
323
319
  pendingProjectRootSessionKey,
324
320
  presenter,
325
- selectedSessionKey,
326
321
  selectedSession?.projectRoot,
327
322
  selectedSessionKeyRef,
328
323
  sessionKey,
@@ -336,7 +331,6 @@ function usePendingProjectRootOverrideCleanup(
336
331
  pendingProjectRoot,
337
332
  pendingProjectRootSessionKey,
338
333
  selectedSession,
339
- selectedSessionKey,
340
334
  } = params;
341
335
  useEffect(() => {
342
336
  if (
@@ -358,7 +352,6 @@ function usePendingProjectRootOverrideCleanup(
358
352
  pendingProjectRoot,
359
353
  pendingProjectRootSessionKey,
360
354
  selectedSession,
361
- selectedSessionKey,
362
355
  ]);
363
356
  }
364
357
 
@@ -385,6 +378,22 @@ function useSelectedSessionAgentSync(params: ReturnType<typeof useNcpChatPageSta
385
378
  }, [presenter, selectedAgentId, selectedSession?.agentId]);
386
379
  }
387
380
 
381
+ function useMaterializedRootSessionRouteSync(
382
+ params: ReturnType<typeof useNcpChatPageState>,
383
+ ) {
384
+ const { agent, presenter, routeSessionKey } = params;
385
+ const materializedSessionKey =
386
+ agent.snapshot.activeRun?.sessionId ??
387
+ agent.visibleMessages.find((message) => message.sessionId.trim())?.sessionId ??
388
+ null;
389
+ useEffect(() => {
390
+ if (routeSessionKey || !materializedSessionKey) {
391
+ return;
392
+ }
393
+ presenter.chatSessionListManager.materializeRootSessionRoute(materializedSessionKey);
394
+ }, [materializedSessionKey, presenter, routeSessionKey]);
395
+ }
396
+
388
397
  export function NcpChatPage({ view }: ChatPageProps) {
389
398
  const [presenter] = useState(() => new NcpChatPresenter());
390
399
  const state = useNcpChatPageState(presenter);
@@ -392,6 +401,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
392
401
  usePendingProjectRootOverrideCleanup(state);
393
402
  useNcpChatUiBindings(state);
394
403
  useSelectedSessionAgentSync(state);
404
+ useMaterializedRootSessionRouteSync(state);
395
405
  useChatSessionSync({
396
406
  view,
397
407
  routeSessionKey: state.routeSessionKey,
@@ -1,10 +1,9 @@
1
1
  import { create, type StateCreator } from 'zustand';
2
- import { createNcpSessionId } from '@/features/chat/utils/ncp-session-adapter.utils';
3
2
  import type { SessionRunStatus } from '@/features/chat/types/session-run-status.types';
4
3
  export type ChatSessionListMode = 'time-first' | 'project-first';
5
4
  export type ChatSessionListSnapshot = {
6
5
  selectedSessionKey: string | null;
7
- draftSessionKey: string;
6
+ draftSessionKey: string | null;
8
7
  selectedAgentId: string;
9
8
  query: string;
10
9
  listMode: ChatSessionListMode;
@@ -50,7 +49,7 @@ type ChatSessionListStoreSet = Parameters<StateCreator<ChatSessionListStore>>[0]
50
49
 
51
50
  const initialSnapshot: ChatSessionListSnapshot = {
52
51
  selectedSessionKey: null,
53
- draftSessionKey: createNcpSessionId(),
52
+ draftSessionKey: null,
54
53
  selectedAgentId: 'main',
55
54
  query: '',
56
55
  listMode: 'time-first'
@@ -5,7 +5,7 @@ import type { ThinkingLevel } from '@/shared/lib/api';
5
5
 
6
6
  export type SendMessageParams = {
7
7
  message: string;
8
- sessionKey: string;
8
+ sessionKey?: string;
9
9
  agentId: string;
10
10
  sessionType?: string;
11
11
  model?: string;
@@ -288,7 +288,7 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
288
288
  const isPromotedChildSession = readPromotedChildSession(summary);
289
289
  return {
290
290
  key: summary.sessionId,
291
- createdAt: summary.updatedAt,
291
+ createdAt: summary.createdAt ?? summary.updatedAt,
292
292
  updatedAt: summary.updatedAt,
293
293
  ...(lastMessageAt ? { lastMessageAt } : {}),
294
294
  ...(readAt ? { readAt } : {}),
@@ -10,46 +10,24 @@ import {
10
10
  import { systemStatusManager } from '@/features/system-status/managers/system-status.manager';
11
11
  import { useSystemStatusStore } from '@/features/system-status/stores/system-status.store';
12
12
 
13
- function createPendingBootstrapStatus(): BootstrapStatusView {
14
- return {
15
- phase: 'kernel-starting',
16
- ncpAgent: {
17
- state: 'pending',
18
- },
19
- pluginHydration: {
20
- state: 'pending',
21
- loadedPluginCount: 0,
22
- totalPluginCount: 0,
23
- },
24
- channels: {
25
- state: 'pending',
26
- enabled: [],
27
- },
28
- remote: {
29
- state: 'pending',
30
- },
31
- };
32
- }
33
-
34
13
  export function useSystemStatusSources() {
35
- const runtimeBootstrapStatus = useQuery({
14
+ const runtimeBootstrapStatus = useQuery<BootstrapStatusView>({
36
15
  queryKey: ['runtime-bootstrap-status'],
37
- queryFn: fetchBootstrapStatus,
38
- placeholderData: createPendingBootstrapStatus,
16
+ queryFn: () => fetchBootstrapStatus({
17
+ timeoutMs: 5_000,
18
+ }),
39
19
  refetchInterval: (query) => {
40
20
  return systemStatusManager.getRuntimeBootstrapPollInterval(
41
- query.state.data
21
+ query.state.data,
22
+ query.state.fetchFailureCount
42
23
  );
43
24
  },
44
- refetchIntervalInBackground: true,
45
25
  retry: false,
46
- refetchOnWindowFocus: true,
47
26
  });
48
27
  const runtimeControl = useQuery({
49
28
  queryKey: ['runtime-control'],
50
29
  queryFn: async () => await systemStatusManager.getRuntimeControl(),
51
30
  staleTime: 5_000,
52
- refetchOnWindowFocus: true,
53
31
  });
54
32
 
55
33
  useEffect(() => {
@@ -1,5 +1,6 @@
1
1
  export { useRuntimeControlPanelView, useRuntimeStatusBadgeView, useSystemStatus, useSystemStatusSources } from './hooks/use-system-status';
2
- export { isTransientRuntimeConnectionErrorMessage, systemStatusManager } from './managers/system-status.manager';
2
+ export { systemStatusManager } from './managers/system-status.manager';
3
+ export { isTransientRuntimeConnectionErrorMessage } from '@/shared/lib/transport';
3
4
  export { runtimeUpdateManager } from './managers/runtime-update.manager';
4
5
  export type { SystemStatusState, SystemStatusView } from './types/system-status.types';
5
6
  export { useSystemStatusStore } from './stores/system-status.store';
@@ -16,7 +16,7 @@ describe('getRuntimeBootstrapPollInterval', () => {
16
16
 
17
17
  it('keeps polling while bootstrap status is missing', () => {
18
18
  expect(systemStatusManager.getRuntimeBootstrapPollInterval(undefined)).toBe(
19
- 500
19
+ 1000
20
20
  );
21
21
  });
22
22
 
@@ -40,7 +40,7 @@ describe('getRuntimeBootstrapPollInterval', () => {
40
40
  state: 'pending',
41
41
  },
42
42
  })
43
- ).toBe(500);
43
+ ).toBe(1000);
44
44
  });
45
45
 
46
46
  it('continues polling even when bootstrap status reports an ncp agent error', () => {
@@ -65,7 +65,7 @@ describe('getRuntimeBootstrapPollInterval', () => {
65
65
  },
66
66
  lastError: 'startup failed',
67
67
  })
68
- ).toBe(500);
68
+ ).toBe(2000);
69
69
  });
70
70
 
71
71
  it('stops polling once the ncp agent is ready', () => {
@@ -121,6 +121,16 @@ describe('getRuntimeBootstrapPollInterval', () => {
121
121
  state: 'disabled',
122
122
  },
123
123
  })
124
- ).toBe(500);
124
+ ).toBe(1000);
125
125
  });
126
+
127
+ it('backs off polling after transport failures', () => {
128
+ expect(
129
+ systemStatusManager.getRuntimeBootstrapPollInterval(undefined, 1)
130
+ ).toBe(2000);
131
+ expect(
132
+ systemStatusManager.getRuntimeBootstrapPollInterval(undefined, 3)
133
+ ).toBe(5000);
134
+ });
135
+
126
136
  });
@@ -1,10 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import type { BootstrapStatusView } from '@/shared/lib/api';
3
3
  import { appQueryClient } from '@/app-query-client';
4
- import {
5
- isTransientRuntimeConnectionErrorMessage,
6
- systemStatusManager,
7
- } from './system-status.manager';
4
+ import { systemStatusManager } from './system-status.manager';
5
+ import { isTransientRuntimeConnectionErrorMessage } from '@/shared/lib/transport';
8
6
  import { useSystemStatusStore } from '@/features/system-status/stores/system-status.store';
9
7
 
10
8
  const readyBootstrapStatus: BootstrapStatusView = {
@@ -76,9 +74,6 @@ describe('systemStatusManager', () => {
76
74
  });
77
75
 
78
76
  it('enters recovering only after the page has previously reached ready', async () => {
79
- const invalidateQueriesSpy = vi
80
- .spyOn(appQueryClient, 'invalidateQueries')
81
- .mockResolvedValue(undefined as never);
82
77
  const refetchQueriesSpy = vi
83
78
  .spyOn(appQueryClient, 'refetchQueries')
84
79
  .mockResolvedValue(undefined as never);
@@ -93,7 +88,6 @@ describe('systemStatusManager', () => {
93
88
  systemStatusManager.reportBootstrapStatus(readyBootstrapStatus);
94
89
 
95
90
  expect(useSystemStatusStore.getState().state.lifecyclePhase).toBe('ready');
96
- expect(invalidateQueriesSpy).toHaveBeenCalled();
97
91
  expect(refetchQueriesSpy).toHaveBeenCalledWith({ type: 'active' });
98
92
  });
99
93
 
@@ -21,8 +21,14 @@ import {
21
21
  initialSystemStatusState,
22
22
  useSystemStatusStore,
23
23
  } from '@/features/system-status/stores/system-status.store';
24
+ import { isTransientRuntimeConnectionErrorMessage } from '@/shared/lib/transport';
24
25
 
25
26
  const RECOVERY_TIMEOUT_MS = 30_000;
27
+ const RUNTIME_BOOTSTRAP_PROBE_POLICY = {
28
+ activePollIntervalMs: 1_000,
29
+ errorPollIntervalMs: 2_000,
30
+ maxErrorPollIntervalMs: 5_000,
31
+ } as const;
26
32
 
27
33
  function getErrorMessage(error: unknown): string {
28
34
  if (error instanceof Error) {
@@ -53,46 +59,34 @@ function resolveActionHelp(action: RuntimeControlAction): string {
53
59
  return t('runtimeControlRestartingAppHelp');
54
60
  }
55
61
 
56
- export function isTransientRuntimeConnectionErrorMessage(
57
- message: string
58
- ): boolean {
59
- const normalized = message.trim().toLowerCase();
60
- if (!normalized) {
61
- return false;
62
- }
63
- return (
64
- normalized.includes('failed to fetch') ||
65
- normalized.includes('networkerror') ||
66
- normalized.includes('network request failed') ||
67
- normalized.includes('load failed') ||
68
- normalized.includes('request timed out') ||
69
- normalized.includes('timed out waiting for remote request response') ||
70
- normalized.includes('remote transport connection closed') ||
71
- normalized.includes('websocket error') ||
72
- normalized.includes('fetch failed on ') ||
73
- normalized.includes('stream request failed for ') ||
74
- normalized.includes('ncp fetch failed for ')
75
- );
76
- }
77
-
78
62
  export class SystemStatusManager {
79
63
  private recoveryTimeoutId: number | null = null;
80
64
 
81
65
  getRuntimeBootstrapPollInterval = (
82
- status: BootstrapStatusView | null | undefined
66
+ status: BootstrapStatusView | null | undefined,
67
+ fetchFailureCount = 0
83
68
  ): number | false => {
84
69
  const { lifecyclePhase, activeSystemAction } = this.getState();
70
+ if (fetchFailureCount > 0) {
71
+ return Math.min(
72
+ RUNTIME_BOOTSTRAP_PROBE_POLICY.maxErrorPollIntervalMs,
73
+ RUNTIME_BOOTSTRAP_PROBE_POLICY.errorPollIntervalMs * fetchFailureCount
74
+ );
75
+ }
85
76
  if (
86
77
  lifecyclePhase === 'recovering' ||
87
78
  lifecyclePhase === 'stalled' ||
88
79
  activeSystemAction?.lifecycle === 'recovering'
89
80
  ) {
90
- return 500;
81
+ return RUNTIME_BOOTSTRAP_PROBE_POLICY.activePollIntervalMs;
91
82
  }
92
83
  if (status?.ncpAgent.state === 'ready') {
93
84
  return false;
94
85
  }
95
- return 500;
86
+ if (status?.ncpAgent.state === 'error' || status?.phase === 'error') {
87
+ return RUNTIME_BOOTSTRAP_PROBE_POLICY.errorPollIntervalMs;
88
+ }
89
+ return RUNTIME_BOOTSTRAP_PROBE_POLICY.activePollIntervalMs;
96
90
  };
97
91
 
98
92
  getRuntimeControl = async (): Promise<RuntimeControlView> => {
@@ -343,10 +337,7 @@ export class SystemStatusManager {
343
337
  });
344
338
 
345
339
  if (shouldRefreshQueries) {
346
- void Promise.all([
347
- appQueryClient.invalidateQueries(),
348
- appQueryClient.refetchQueries({ type: 'active' }),
349
- ]);
340
+ void appQueryClient.refetchQueries({ type: 'active' });
350
341
  }
351
342
  };
352
343
 
@@ -413,7 +404,6 @@ export class SystemStatusManager {
413
404
  const view = await this.getRuntimeControl();
414
405
  this.syncRuntimeControlQueryCache(view);
415
406
  this.reportRuntimeControlView(view);
416
- await appQueryClient.invalidateQueries({ queryKey: ['runtime-control'] });
417
407
  } catch (error) {
418
408
  this.reportRuntimeControlError(error);
419
409
  }
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
3
3
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
4
  import { MemoryRouter } from 'react-router-dom';
5
5
  import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import type * as SystemStatusModule from '@/features/system-status';
6
7
  import { useRuntimeUpdateStore } from '@/features/system-status';
7
8
  import { BrandHeader } from '@/shared/components/common/brand-header';
8
9
  import { setLanguage } from '@/shared/lib/i18n';
@@ -13,9 +14,7 @@ const mocks = vi.hoisted(() => ({
13
14
  }));
14
15
 
15
16
  vi.mock('@/features/system-status', async () => {
16
- const actual = await vi.importActual<typeof import('@/features/system-status')>(
17
- '@/features/system-status'
18
- );
17
+ const actual = await vi.importActual<typeof SystemStatusModule>('@/features/system-status');
19
18
  return {
20
19
  ...actual,
21
20
  runtimeUpdateManager: {
@@ -144,4 +143,86 @@ describe('BrandHeader', () => {
144
143
  expect(mocks.applyDownloadedUpdate).toHaveBeenCalledTimes(1);
145
144
  expect(mocks.downloadUpdate).not.toHaveBeenCalled();
146
145
  });
146
+
147
+ it('shows a warning icon with the blocked update reason instead of a visible failure label', async () => {
148
+ useRuntimeUpdateStore.setState({
149
+ supported: true,
150
+ initialized: true,
151
+ busyAction: null,
152
+ snapshot: {
153
+ status: 'blocked',
154
+ installationKind: 'npm-runtime-bundle',
155
+ channel: 'stable',
156
+ hostVersion: '0.19.4',
157
+ currentVersion: '0.19.4',
158
+ availableVersion: null,
159
+ downloadedVersion: null,
160
+ minimumHostVersion: null,
161
+ releaseNotesUrl: null,
162
+ lastCheckedAt: null,
163
+ progress: null,
164
+ canAutoDownload: true,
165
+ canApplyInApp: false,
166
+ requiresRestart: false,
167
+ blockReason: 'signature-verification-unavailable',
168
+ recoveryCommand: 'Set NEXTCLAW_UPDATE_BUNDLE_PUBLIC_KEY',
169
+ errorMessage: 'Runtime bundle updates require a configured update public key.',
170
+ preferences: {
171
+ automaticChecks: true,
172
+ autoDownload: true
173
+ }
174
+ }
175
+ });
176
+
177
+ renderBrandHeader();
178
+
179
+ expect(screen.queryByText('更新异常')).toBeNull();
180
+ const issueIcon = screen.getByLabelText('更新被阻塞');
181
+
182
+ expect(issueIcon.textContent).toBe('!');
183
+ expect(issueIcon.getAttribute('title')).toContain('更新被阻塞');
184
+ expect(issueIcon.getAttribute('title')).toContain('根因:缺少更新签名公钥,无法验证更新包来源');
185
+ expect(issueIcon.getAttribute('title')).toContain('Runtime bundle updates require a configured update public key.');
186
+ expect(issueIcon.getAttribute('title')).toContain('Set NEXTCLAW_UPDATE_BUNDLE_PUBLIC_KEY');
187
+ });
188
+
189
+ it('uses the failed update wording only for failed snapshots', async () => {
190
+ useRuntimeUpdateStore.setState({
191
+ supported: true,
192
+ initialized: true,
193
+ busyAction: null,
194
+ snapshot: {
195
+ status: 'failed',
196
+ installationKind: 'npm-runtime-bundle',
197
+ channel: 'stable',
198
+ hostVersion: '0.19.4',
199
+ currentVersion: '0.19.3',
200
+ availableVersion: '0.19.4',
201
+ downloadedVersion: null,
202
+ minimumHostVersion: null,
203
+ releaseNotesUrl: null,
204
+ lastCheckedAt: null,
205
+ progress: null,
206
+ canAutoDownload: true,
207
+ canApplyInApp: false,
208
+ requiresRestart: false,
209
+ blockReason: null,
210
+ recoveryCommand: null,
211
+ errorMessage: 'runtime bundle sha256 mismatch',
212
+ preferences: {
213
+ automaticChecks: true,
214
+ autoDownload: true
215
+ }
216
+ }
217
+ });
218
+
219
+ renderBrandHeader();
220
+
221
+ const issueIcon = screen.getByLabelText('更新失败');
222
+
223
+ expect(issueIcon.textContent).toBe('!');
224
+ expect(issueIcon.getAttribute('title')).toContain('更新失败');
225
+ expect(issueIcon.getAttribute('title')).toContain('runtime bundle sha256 mismatch');
226
+ expect(screen.queryByText('更新被阻塞')).toBeNull();
227
+ });
147
228
  });