@nextclaw/ui 0.12.10 → 0.12.11

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 (169) hide show
  1. package/CHANGELOG.md +51 -10
  2. package/dist/assets/ChannelsList-SQ7Oxotv.js +8 -0
  3. package/dist/assets/DocBrowser-BCO2k6XD.js +1 -0
  4. package/dist/assets/{DocBrowser-DMfr0Oow.js → DocBrowser-rDOjI3ga.js} +1 -1
  5. package/dist/assets/{DocBrowserContext-BXydqby-.js → DocBrowserContext-BUq3Wo8O.js} +1 -1
  6. package/dist/assets/{LogoBadge-hO7tY7hE.js → LogoBadge-DP8Ye7wJ.js} +1 -1
  7. package/dist/assets/ModelConfig-C77Ae9ru.js +1 -0
  8. package/dist/assets/ProviderScopedModelInput-CEnK61uo.js +1 -0
  9. package/dist/assets/ProvidersList-BCupBayq.js +1 -0
  10. package/dist/assets/RuntimeConfig-Ad-CAcmy.js +1 -0
  11. package/dist/assets/SearchConfig-BfCz4wJ4.js +1 -0
  12. package/dist/assets/SecretsConfig-DjmBIhyy.js +3 -0
  13. package/dist/assets/SessionsConfig-CvjxU40H.js +2 -0
  14. package/dist/assets/{book-open-DzdUViDm.js → book-open-BE8M56IM.js} +1 -1
  15. package/dist/assets/chat-page-JKC6ln-y.js +58 -0
  16. package/dist/assets/chat-session-display-YcRMrAMa.js +1 -0
  17. package/dist/assets/{chunk-JZWAC4HX-C5dEc8hV.js → chunk-JZWAC4HX-erTUn3b8.js} +1 -1
  18. package/dist/assets/client-CszWMVKi.js +7 -0
  19. package/dist/assets/{config-split-page-BUout_Ak.js → config-split-page-BAGSzUR3.js} +1 -1
  20. package/dist/assets/{createLucideIcon-dy5ie7Ox.js → createLucideIcon-CCiTGX8L.js} +1 -1
  21. package/dist/assets/desktop-DfkLlkG2.js +1 -0
  22. package/dist/assets/desktop-update-config-BXeGlqHD.js +1 -0
  23. package/dist/assets/dialog-BghZFPch.js +5 -0
  24. package/dist/assets/{dist-Cy7_j6hA.js → dist-Dd9cr-kz.js} +1 -1
  25. package/dist/assets/dist-ZwoAXs46.js +9 -0
  26. package/dist/assets/{download-BD0ETkB-.js → download-D7LOizcW.js} +1 -1
  27. package/dist/assets/es2015-CEAreese.js +41 -0
  28. package/dist/assets/{external-link-kZSAO8nT.js → external-link-qsnCMhw1.js} +1 -1
  29. package/dist/assets/{hash-BHJC2Ovu.js → hash-0zjWsNl-.js} +1 -1
  30. package/dist/assets/{i18n-CpTZLchQ.js → i18n-DvzXOGQX.js} +1 -1
  31. package/dist/assets/index-DvVTC9FF.css +1 -0
  32. package/dist/assets/index-lr6rQUSd.js +2 -0
  33. package/dist/assets/key-round-BLe9D8ND.js +1 -0
  34. package/dist/assets/loader-circle-wj7kARHv.js +1 -0
  35. package/dist/assets/{logos-B7gRObP8.js → logos-_v5b2SdG.js} +1 -1
  36. package/dist/assets/marketplace-page-CAAk1Khc.js +1 -0
  37. package/dist/assets/marketplace-page-CfCiq90S.js +49 -0
  38. package/dist/assets/mcp-marketplace-page-D0Pp9Hs-.js +40 -0
  39. package/dist/assets/play-o6NmwGTi.js +1 -0
  40. package/dist/assets/plus-I9pBS4Fl.js +1 -0
  41. package/dist/assets/{refresh-cw-Bcv40SXy.js → refresh-cw-MNqgR3LZ.js} +1 -1
  42. package/dist/assets/remote-C9fXm4V5.js +1 -0
  43. package/dist/assets/{save-EqJPOF0G.js → save-D4bObrmH.js} +1 -1
  44. package/dist/assets/search-DxmL3IWE.js +1 -0
  45. package/dist/assets/security-config-BUm6FFfl.js +1 -0
  46. package/dist/assets/select-BILPf7zs.js +1 -0
  47. package/dist/assets/setting-row-BATDgg4r.js +1 -0
  48. package/dist/assets/skeleton-COKMAnJy.js +1 -0
  49. package/dist/assets/{switch-CM29eCAR.js → switch-CBOzecWS.js} +1 -1
  50. package/dist/assets/{tabs-custom-YcZUWn3o.js → tabs-custom-Bx3cNhD-.js} +1 -1
  51. package/dist/assets/tag-chip-zUaDE2-H.js +1 -0
  52. package/dist/assets/{trash-2-mJT6oWa2.js → trash-2-CQUgYyRn.js} +1 -1
  53. package/dist/assets/use-infinite-scroll-loader-B5V2Klve.js +1 -0
  54. package/dist/assets/useConfirmDialog-patAnl1g.js +1 -0
  55. package/dist/assets/{useMutation-CNcz2fgt.js → useMutation-__AYv-Pz.js} +1 -1
  56. package/dist/assets/x-BHUGQIUv.js +1 -0
  57. package/dist/index.html +22 -22
  58. package/module-structure.config.json +7 -0
  59. package/package.json +5 -5
  60. package/src/api/config.ts +10 -0
  61. package/src/api/raw-client.test.ts +1 -1
  62. package/src/api/{raw-client.ts → raw-client.utils.ts} +2 -0
  63. package/src/api/types.ts +40 -0
  64. package/src/app/components/app-manager-provider.tsx +20 -0
  65. package/src/app/managers/app.manager.ts +12 -0
  66. package/src/app.tsx +8 -8
  67. package/src/components/chat/chat-conversation-panel.test.tsx +10 -0
  68. package/src/components/chat/chat-input/ncp-chat-input-availability.utils.test.ts +92 -0
  69. package/src/components/chat/chat-input/ncp-chat-input-availability.utils.ts +45 -0
  70. package/src/components/chat/chat-page-shell.tsx +1 -1
  71. package/src/components/chat/containers/chat-input-bar.container.tsx +24 -12
  72. package/src/components/chat/{ChatSidebar.test.tsx → containers/chat-sidebar.test.tsx} +5 -4
  73. package/src/components/chat/{ChatSidebar.tsx → containers/chat-sidebar.tsx} +13 -37
  74. package/src/components/chat/hooks/use-chat-sidebar-session-label-editor.ts +49 -0
  75. package/src/components/chat/ncp/ncp-app-client-fetch.ts +3 -0
  76. package/src/components/chat/ncp/ncp-chat-input.manager.ts +13 -5
  77. package/src/components/chat/ncp/ncp-chat-page.tsx +21 -2
  78. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +48 -4
  79. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +43 -5
  80. package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +51 -1
  81. package/src/components/config/desktop-update-config.test.tsx +10 -4
  82. package/src/components/config/desktop-update-config.tsx +5 -3
  83. package/src/components/config/runtime-control-card.test.tsx +119 -197
  84. package/src/components/config/runtime-control-card.tsx +20 -70
  85. package/src/components/config/runtime-presence-card.test.tsx +10 -14
  86. package/src/components/config/runtime-presence-card.tsx +7 -5
  87. package/src/components/layout/Sidebar.tsx +4 -4
  88. package/src/components/layout/runtime-status-entry.test.tsx +45 -101
  89. package/src/components/layout/runtime-status-entry.tsx +15 -63
  90. package/src/components/layout/sidebar.layout.test.tsx +11 -5
  91. package/src/{account → features/account}/components/account-panel.tsx +13 -13
  92. package/src/features/account/index.ts +6 -0
  93. package/src/{account → features/account}/managers/account.manager.ts +3 -3
  94. package/src/{components/remote → features/remote/components}/remote-access-page.test.tsx +4 -5
  95. package/src/{components/remote → features/remote/components}/remote-access-page.tsx +15 -13
  96. package/src/{hooks/useRemoteAccess.ts → features/remote/hooks/use-remote-access.ts} +1 -1
  97. package/src/features/remote/index.ts +27 -0
  98. package/src/{remote → features/remote}/managers/remote-access.manager.ts +3 -4
  99. package/src/{remote → features/remote/services}/remote-access-feedback.service.test.ts +1 -1
  100. package/src/features/system-status/hooks/use-system-status.ts +104 -0
  101. package/src/features/system-status/index.ts +12 -0
  102. package/src/features/system-status/managers/system-status.manager.bootstrap-polling.test.ts +126 -0
  103. package/src/features/system-status/managers/system-status.manager.test.ts +142 -0
  104. package/src/features/system-status/managers/system-status.manager.ts +511 -0
  105. package/src/features/system-status/stores/system-status.store.ts +32 -0
  106. package/src/features/system-status/types/system-status.types.ts +73 -0
  107. package/src/features/system-status/utils/system-status.utils.test.ts +132 -0
  108. package/src/features/system-status/utils/system-status.utils.ts +202 -0
  109. package/src/hooks/use-realtime-query-bridge.ts +34 -18
  110. package/src/lib/i18n.chat.ts +8 -0
  111. package/src/platforms/desktop/index.ts +20 -0
  112. package/src/{desktop → platforms/desktop}/managers/desktop-presence.manager.ts +2 -2
  113. package/src/{desktop → platforms/desktop}/managers/desktop-update.manager.ts +2 -2
  114. package/src/{desktop → platforms/desktop}/stores/desktop-presence.store.ts +1 -1
  115. package/src/{desktop → platforms/desktop}/stores/desktop-update.store.ts +1 -1
  116. package/src/stores/ui.store.ts +0 -9
  117. package/src/transport/{app-client.ts → app-client.service.ts} +9 -9
  118. package/src/transport/app-client.test.ts +9 -5
  119. package/src/transport/index.ts +1 -1
  120. package/src/transport/{local.transport.ts → local-transport.service.ts} +14 -12
  121. package/dist/assets/ChannelsList-M9FTK1Ak.js +0 -8
  122. package/dist/assets/DocBrowser-CH7-GxlL.js +0 -1
  123. package/dist/assets/ModelConfig-CNIgLf0e.js +0 -1
  124. package/dist/assets/ProviderScopedModelInput-B3HWP4oz.js +0 -1
  125. package/dist/assets/ProvidersList-CHjMnRhX.js +0 -1
  126. package/dist/assets/RuntimeConfig-psp8nMSG.js +0 -1
  127. package/dist/assets/SearchConfig-CSoKip1f.js +0 -1
  128. package/dist/assets/SecretsConfig-MEt6MjuD.js +0 -3
  129. package/dist/assets/SessionsConfig-DifCiXwR.js +0 -2
  130. package/dist/assets/app-query-client-9jNewezV.js +0 -1
  131. package/dist/assets/chat-page-CLp0UV0Y.js +0 -58
  132. package/dist/assets/chat-session-display-DsYHx0RZ.js +0 -1
  133. package/dist/assets/client-C-8fH7-c.js +0 -7
  134. package/dist/assets/config-CBScxsdV.js +0 -1
  135. package/dist/assets/desktop-update-config-2BS6BMkW.js +0 -1
  136. package/dist/assets/dist-BruyLa92.js +0 -9
  137. package/dist/assets/index-mW8W2FUu.css +0 -1
  138. package/dist/assets/index-zDZfXoI4.js +0 -6
  139. package/dist/assets/infiniteQueryBehavior-CyER9hv0.js +0 -1
  140. package/dist/assets/loader-circle-Bc2gCU33.js +0 -1
  141. package/dist/assets/marketplace-page-3qVMnF3d.js +0 -1
  142. package/dist/assets/marketplace-page-BhFIeQzI.js +0 -49
  143. package/dist/assets/mcp-marketplace-page-DYfteJ1D.js +0 -40
  144. package/dist/assets/page-layout-0UcO9H9Z.js +0 -1
  145. package/dist/assets/play-CKDjSQFL.js +0 -1
  146. package/dist/assets/plus-CG0QrVY_.js +0 -1
  147. package/dist/assets/refresh-ccw-COVhNHtN.js +0 -1
  148. package/dist/assets/remote-access-page-CWHG-sug.js +0 -1
  149. package/dist/assets/rotate-cw-oHMKJMC8.js +0 -1
  150. package/dist/assets/search-BCAlB8nz.js +0 -1
  151. package/dist/assets/security-config-Slh0Mayz.js +0 -1
  152. package/dist/assets/select-CVz0t7MF.js +0 -41
  153. package/dist/assets/setting-row-CbVHAuQt.js +0 -1
  154. package/dist/assets/skeleton-D5rdKvzy.js +0 -1
  155. package/dist/assets/status-dot-DpPtVzQT.js +0 -1
  156. package/dist/assets/tag-chip-DMXdnLcj.js +0 -1
  157. package/dist/assets/use-infinite-scroll-loader-DJ1L81Dz.js +0 -1
  158. package/dist/assets/useConfirmDialog-BsVuqu1x.js +0 -1
  159. package/dist/assets/x-Czwxm82I.js +0 -1
  160. package/src/hooks/use-runtime-control.ts +0 -24
  161. package/src/presenter/app-presenter-context.tsx +0 -20
  162. package/src/presenter/app.presenter.ts +0 -12
  163. package/src/runtime-control/runtime-control.manager.ts +0 -118
  164. /package/dist/assets/{config-hints-BhTmc9P1.js → config-hints-DSQQbeOA.js} +0 -0
  165. /package/src/{account → features/account}/stores/account.store.ts +0 -0
  166. /package/src/{remote → features/remote/services}/remote-access-feedback.service.ts +0 -0
  167. /package/src/{remote/remote-access.query.ts → features/remote/services/remote-access-query.service.ts} +0 -0
  168. /package/src/{remote → features/remote}/stores/remote-access.store.ts +0 -0
  169. /package/src/{desktop → platforms/desktop/types}/desktop-update.types.ts +0 -0
@@ -0,0 +1,45 @@
1
+ import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
2
+
3
+ type NcpChatInputAvailabilitySnapshot = Pick<
4
+ ChatInputSnapshot,
5
+ 'isProviderStateResolved' | 'modelOptions' | 'sessionTypeUnavailable'
6
+ >;
7
+
8
+ export function hasNcpChatModelOptions(
9
+ snapshot: NcpChatInputAvailabilitySnapshot
10
+ ): boolean {
11
+ return snapshot.modelOptions.length > 0;
12
+ }
13
+
14
+ export function isNcpChatModelOptionsLoading(
15
+ snapshot: NcpChatInputAvailabilitySnapshot
16
+ ): boolean {
17
+ return !snapshot.isProviderStateResolved && !hasNcpChatModelOptions(snapshot);
18
+ }
19
+
20
+ export function isNcpChatModelOptionsEmpty(
21
+ snapshot: NcpChatInputAvailabilitySnapshot
22
+ ): boolean {
23
+ return snapshot.isProviderStateResolved && !hasNcpChatModelOptions(snapshot);
24
+ }
25
+
26
+ export function isNcpChatComposerDisabled(
27
+ snapshot: NcpChatInputAvailabilitySnapshot
28
+ ): boolean {
29
+ return snapshot.sessionTypeUnavailable;
30
+ }
31
+
32
+ export function isNcpChatSendDisabled(params: {
33
+ hasSendableDraft: boolean;
34
+ snapshot: NcpChatInputAvailabilitySnapshot;
35
+ isRuntimeBlocked: boolean;
36
+ }): boolean {
37
+ const { hasSendableDraft, isRuntimeBlocked, snapshot } = params;
38
+
39
+ return (
40
+ isRuntimeBlocked ||
41
+ !hasSendableDraft ||
42
+ !hasNcpChatModelOptions(snapshot) ||
43
+ snapshot.sessionTypeUnavailable
44
+ );
45
+ }
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from "react";
2
2
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
3
- import { ChatSidebar } from "@/components/chat/ChatSidebar";
3
+ import { ChatSidebar } from "@/components/chat/containers/chat-sidebar";
4
4
  import { ChatConversationPanel } from "@/components/chat/chat-conversation-panel";
5
5
  import { AgentsPage } from "@/components/agents/agents-page";
6
6
  import { CronConfig } from "@/components/config/CronConfig";
@@ -16,6 +16,13 @@ import {
16
16
  type ChatThinkingLevel
17
17
  } from '@/components/chat/adapters/chat-input-bar.adapter';
18
18
  import { deriveSelectedSkillsFromComposer } from '@/components/chat/chat-composer-state';
19
+ import {
20
+ hasNcpChatModelOptions,
21
+ isNcpChatComposerDisabled,
22
+ isNcpChatModelOptionsEmpty,
23
+ isNcpChatModelOptionsLoading,
24
+ isNcpChatSendDisabled,
25
+ } from '@/components/chat/chat-input/ncp-chat-input-availability.utils';
19
26
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
20
27
  import {
21
28
  CHAT_RECENT_MODELS_MIN_OPTIONS,
@@ -27,6 +34,7 @@ import {
27
34
  } from '@/components/chat/chat-recent-skills.manager';
28
35
  import { useI18n } from '@/components/providers/I18nProvider';
29
36
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
37
+ import { useChatRuntimeAvailability } from '@/features/system-status';
30
38
  import type { SessionSkillEntryView } from '@/api/types';
31
39
  import { t } from '@/lib/i18n';
32
40
  import { toast } from 'sonner';
@@ -83,11 +91,13 @@ export function ChatInputBarContainer() {
83
91
  const presenter = usePresenter();
84
92
  const { language } = useI18n();
85
93
  const snapshot = useChatInputStore((state) => state.snapshot);
94
+ const runtimeAvailability = useChatRuntimeAvailability();
86
95
  const [slashQuery, setSlashQuery] = useState<string | null>(null);
87
96
  const inputBarRef = useRef<ChatInputBarHandle | null>(null);
88
97
  const fileInputRef = useRef<HTMLInputElement | null>(null);
89
98
 
90
99
  const skillScopeLabels = useMemo<Record<'builtin' | 'project' | 'workspace', string>>(() => {
100
+ void language;
91
101
  return {
92
102
  builtin: t('chatSkillScopeBuiltin'),
93
103
  project: t('chatSkillScopeProject'),
@@ -96,6 +106,7 @@ export function ChatInputBarContainer() {
96
106
  }, [language]);
97
107
  const slashTexts = useMemo(
98
108
  () => {
109
+ void language;
99
110
  return {
100
111
  slashSkillSubtitle: t('chatSlashTypeSkill'),
101
112
  slashSkillSpecLabel: t('chatSlashSkillSpec'),
@@ -124,17 +135,14 @@ export function ChatInputBarContainer() {
124
135
  minAvailableCount: CHAT_RECENT_SKILLS_MIN_OPTIONS
125
136
  });
126
137
 
127
- const hasModelOptions = modelRecords.length > 0;
128
- const isModelOptionsLoading = !snapshot.isProviderStateResolved && !hasModelOptions;
129
- const isModelOptionsEmpty = snapshot.isProviderStateResolved && !hasModelOptions;
130
- const inputDisabled =
131
- ((isModelOptionsLoading || isModelOptionsEmpty) && !snapshot.isSending) || snapshot.sessionTypeUnavailable;
138
+ const hasModelOptions = hasNcpChatModelOptions(snapshot);
139
+ const isModelOptionsLoading = isNcpChatModelOptionsLoading(snapshot);
140
+ const isModelOptionsEmpty = isNcpChatModelOptionsEmpty(snapshot);
141
+ const inputDisabled = isNcpChatComposerDisabled(snapshot);
132
142
  const attachmentSupported = typeof presenter.chatInputManager.addAttachments === 'function';
133
- const textareaPlaceholder = isModelOptionsLoading
134
- ? ''
135
- : hasModelOptions
136
- ? t('chatInputPlaceholder')
137
- : t('chatModelNoOptions');
143
+ const textareaPlaceholder = isModelOptionsEmpty
144
+ ? t('chatModelNoOptions')
145
+ : t('chatInputPlaceholder');
138
146
  const recentModelsLabel = t('chatPickerRecentModels');
139
147
  const allModelsLabel = t('chatPickerAllModels');
140
148
  const recentSkillsLabel = t('chatPickerRecent');
@@ -298,10 +306,14 @@ export function ChatInputBarContainer() {
298
306
  ],
299
307
  skillPicker,
300
308
  actions: {
301
- sendError: snapshot.sendError,
309
+ sendError: runtimeAvailability.isBlocked ? null : snapshot.sendError,
302
310
  isSending: snapshot.isSending,
303
311
  canStopGeneration: snapshot.canStopGeneration,
304
- sendDisabled: !hasSendableDraft || !hasModelOptions || snapshot.sessionTypeUnavailable,
312
+ sendDisabled: isNcpChatSendDisabled({
313
+ snapshot,
314
+ hasSendableDraft,
315
+ isRuntimeBlocked: runtimeAvailability.isBlocked,
316
+ }),
305
317
  stopDisabled: !snapshot.canStopGeneration,
306
318
  stopHint: resolvedStopHint,
307
319
  sendButtonLabel: t('chatSend'),
@@ -1,7 +1,7 @@
1
1
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
2
  import { MemoryRouter } from 'react-router-dom';
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
- import { ChatSidebar } from '@/components/chat/ChatSidebar';
4
+ import { ChatSidebar } from '@/components/chat/containers/chat-sidebar';
5
5
  import type { NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
6
6
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
7
7
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
@@ -107,9 +107,10 @@ vi.mock('@/components/providers/ThemeProvider', () => ({
107
107
  })
108
108
  }));
109
109
 
110
- vi.mock('@/stores/ui.store', () => ({
111
- useUiStore: (selector: (state: { connectionStatus: string }) => unknown) =>
112
- selector({ connectionStatus: 'connected' })
110
+ vi.mock('@/features/system-status', () => ({
111
+ useSystemStatus: () => ({
112
+ connectionStatus: 'connected'
113
+ })
113
114
  }));
114
115
 
115
116
  function resetSidebarTestState() {
@@ -14,7 +14,7 @@ import {
14
14
  type ChatSidebarProjectGroup
15
15
  } from '@/components/chat/chat-sidebar-project-groups';
16
16
  import { resolveSessionContextView } from '@/lib/session-context.utils';
17
- import { useChatSessionLabel } from '@/components/chat/hooks/use-chat-session-label';
17
+ import { useChatSidebarSessionLabelEditor } from '@/components/chat/hooks/use-chat-sidebar-session-label-editor';
18
18
  import { useNcpSessionListView, type NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
19
19
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
20
20
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
@@ -31,7 +31,7 @@ import { useI18n } from '@/components/providers/I18nProvider';
31
31
  import { useTheme } from '@/components/providers/ThemeProvider';
32
32
  import { useDocBrowser } from '@/components/doc-browser';
33
33
  import { SidebarActionItem, SidebarNavLinkItem, SidebarSelectItem } from '@/components/layout/sidebar-items';
34
- import { useUiStore } from '@/stores/ui.store';
34
+ import { useSystemStatus } from '@/features/system-status';
35
35
  import {
36
36
  AlarmClock,
37
37
  Bot,
@@ -169,22 +169,17 @@ function useChatSessionUnreadState(
169
169
 
170
170
  return optimisticReadAtBySessionKey;
171
171
  }
172
-
173
172
  export function ChatSidebar() {
174
173
  const presenter = usePresenter();
175
174
  const docBrowser = useDocBrowser();
176
175
  const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
177
- const [editingSessionKey, setEditingSessionKey] = useState<string | null>(null);
178
- const [draftLabel, setDraftLabel] = useState('');
179
- const [savingSessionKey, setSavingSessionKey] = useState<string | null>(null);
180
176
  const inputSnapshot = useChatInputStore((state) => state.snapshot);
181
177
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
182
- const connectionStatus = useUiStore((state) => state.connectionStatus);
178
+ const systemStatus = useSystemStatus();
183
179
  const agentsQuery = useAgents();
184
180
  const { isLoading, items } = useNcpSessionListView();
185
181
  const { language, setLanguage } = useI18n();
186
182
  const { theme, setTheme } = useTheme();
187
- const updateSessionLabel = useChatSessionLabel();
188
183
  const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
189
184
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
190
185
  const agentsById = useMemo(
@@ -221,39 +216,20 @@ export function ChatSidebar() {
221
216
  listSnapshot.selectedSessionKey,
222
217
  presenter.chatSessionListManager.markSessionRead,
223
218
  );
219
+ const {
220
+ editingSessionKey,
221
+ draftLabel,
222
+ savingSessionKey,
223
+ setDraftLabel,
224
+ startEditingSessionLabel,
225
+ cancelEditingSessionLabel,
226
+ saveSessionLabel,
227
+ } = useChatSidebarSessionLabelEditor();
224
228
  const handleLanguageSwitch = (nextLang: I18nLanguage) => {
225
229
  if (language === nextLang) return;
226
230
  setLanguage(nextLang);
227
231
  window.location.reload();
228
232
  };
229
- const startEditingSessionLabel = (session: SessionEntryView) => {
230
- setEditingSessionKey(session.key);
231
- setDraftLabel(session.label?.trim() ?? '');
232
- };
233
- const cancelEditingSessionLabel = () => {
234
- setEditingSessionKey(null);
235
- setDraftLabel('');
236
- setSavingSessionKey(null);
237
- };
238
- const saveSessionLabel = async (session: SessionEntryView) => {
239
- const normalizedLabel = draftLabel.trim();
240
- const currentLabel = session.label?.trim() ?? '';
241
- if (normalizedLabel === currentLabel) {
242
- cancelEditingSessionLabel();
243
- return;
244
- }
245
-
246
- setSavingSessionKey(session.key);
247
- try {
248
- await updateSessionLabel({
249
- sessionKey: session.key,
250
- label: normalizedLabel || null
251
- });
252
- cancelEditingSessionLabel();
253
- } catch {
254
- setSavingSessionKey(null);
255
- }
256
- };
257
233
  const renderSessionItem = ({ session, runStatus }: NcpSessionListItemView) => {
258
234
  const active = listSnapshot.selectedSessionKey === session.key;
259
235
  const optimisticReadAt = optimisticReadAtBySessionKey[session.key];
@@ -306,7 +282,7 @@ export function ChatSidebar() {
306
282
  <div className="px-5 pt-5 pb-3">
307
283
  <BrandHeader
308
284
  className="flex items-center gap-2.5 min-w-0"
309
- suffix={<StatusBadge status={connectionStatus} />}
285
+ suffix={<StatusBadge status={systemStatus.connectionStatus} />}
310
286
  />
311
287
  </div>
312
288
 
@@ -0,0 +1,49 @@
1
+ import { useState } from 'react';
2
+ import type { SessionEntryView } from '@/api/types';
3
+ import { useChatSessionLabel } from '@/components/chat/hooks/use-chat-session-label';
4
+
5
+ export function useChatSidebarSessionLabelEditor() {
6
+ const updateSessionLabel = useChatSessionLabel();
7
+ const [editingSessionKey, setEditingSessionKey] = useState<string | null>(null);
8
+ const [draftLabel, setDraftLabel] = useState('');
9
+ const [savingSessionKey, setSavingSessionKey] = useState<string | null>(null);
10
+
11
+ const startEditingSessionLabel = (session: SessionEntryView) => {
12
+ setEditingSessionKey(session.key);
13
+ setDraftLabel(session.label?.trim() ?? '');
14
+ };
15
+ const cancelEditingSessionLabel = () => {
16
+ setEditingSessionKey(null);
17
+ setDraftLabel('');
18
+ setSavingSessionKey(null);
19
+ };
20
+ const saveSessionLabel = async (session: SessionEntryView) => {
21
+ const normalizedLabel = draftLabel.trim();
22
+ const currentLabel = session.label?.trim() ?? '';
23
+ if (normalizedLabel === currentLabel) {
24
+ cancelEditingSessionLabel();
25
+ return;
26
+ }
27
+
28
+ setSavingSessionKey(session.key);
29
+ try {
30
+ await updateSessionLabel({
31
+ sessionKey: session.key,
32
+ label: normalizedLabel || null,
33
+ });
34
+ cancelEditingSessionLabel();
35
+ } catch {
36
+ setSavingSessionKey(null);
37
+ }
38
+ };
39
+
40
+ return {
41
+ editingSessionKey,
42
+ draftLabel,
43
+ savingSessionKey,
44
+ setDraftLabel,
45
+ startEditingSessionLabel,
46
+ cancelEditingSessionLabel,
47
+ saveSessionLabel,
48
+ };
49
+ }
@@ -1,3 +1,5 @@
1
+ import { systemStatusManager } from '@/features/system-status';
2
+
1
3
  type FetchLike = typeof fetch;
2
4
 
3
5
  function formatFetchTarget(input: RequestInfo | URL): string {
@@ -38,6 +40,7 @@ export function createNcpAppClientFetch(): FetchLike {
38
40
  ...init
39
41
  });
40
42
  } catch (error) {
43
+ systemStatusManager.reportTransportFailure(formatUnknownFetchError(error));
41
44
  const method = (init?.method || 'GET').toUpperCase();
42
45
  const target = formatFetchTarget(input);
43
46
  throw createErrorWithCause(
@@ -21,10 +21,12 @@ import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-s
21
21
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
22
22
  import type { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
23
23
  import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
24
+ import { isNcpChatSendDisabled } from '@/components/chat/chat-input/ncp-chat-input-availability.utils';
24
25
  import { chatRecentModelsManager } from '@/components/chat/chat-recent-models.manager';
25
26
  import { chatRecentSkillsManager } from '@/components/chat/chat-recent-skills.manager';
26
27
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
27
28
  import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState';
29
+ import { systemStatusManager } from '@/features/system-status';
28
30
 
29
31
  export class NcpChatInputManager {
30
32
  private readonly sessionPreferenceSync = new ChatSessionPreferenceSync(updateNcpSession);
@@ -139,7 +141,7 @@ export class NcpChatInputManager {
139
141
  if (attachments.length === 0) {
140
142
  return [];
141
143
  }
142
- const snapshot = useChatInputStore.getState().snapshot;
144
+ const { snapshot } = useChatInputStore.getState();
143
145
  const existingSignatures = new Set(snapshot.attachments.map(this.buildAttachmentSignature));
144
146
  const nextAttachments = this.dedupeAttachments([...snapshot.attachments, ...attachments]);
145
147
  const insertedAttachments = nextAttachments.filter(
@@ -173,12 +175,18 @@ export class NcpChatInputManager {
173
175
  const sessionSnapshot = useChatSessionListStore.getState().snapshot;
174
176
  const threadSnapshot = useChatThreadStore.getState().snapshot;
175
177
  const message = inputSnapshot.draft.trim();
176
- const attachments = inputSnapshot.attachments;
178
+ const { attachments } = inputSnapshot;
177
179
  const parts = deriveNcpMessagePartsFromComposer(inputSnapshot.composerNodes, attachments);
178
180
  const hasSendableContent = parts.some(
179
181
  (part) => part.type !== 'text' || part.text.trim().length > 0
180
182
  );
181
- if (!hasSendableContent) {
183
+ if (
184
+ isNcpChatSendDisabled({
185
+ snapshot: inputSnapshot,
186
+ hasSendableDraft: hasSendableContent,
187
+ isRuntimeBlocked: systemStatusManager.isChatInteractionBlocked(),
188
+ })
189
+ ) {
182
190
  return;
183
191
  }
184
192
  const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
@@ -237,7 +245,7 @@ export class NcpChatInputManager {
237
245
  };
238
246
 
239
247
  setSelectedSkills = (next: SetStateAction<string[]>) => {
240
- const snapshot = useChatInputStore.getState().snapshot;
248
+ const { snapshot } = useChatInputStore.getState();
241
249
  const { selectedSkills: prev } = snapshot;
242
250
  const value = this.resolveUpdateValue(prev, next);
243
251
  if (this.isSameStringArray(value, prev)) {
@@ -292,7 +300,7 @@ export class NcpChatInputManager {
292
300
  };
293
301
 
294
302
  private reconcileThinkingForModel = (model: string): void => {
295
- const snapshot = useChatInputStore.getState().snapshot;
303
+ const { snapshot } = useChatInputStore.getState();
296
304
  const modelOption = snapshot.modelOptions.find((option) => option.value === model);
297
305
  const { selectedThinkingLevel } = snapshot;
298
306
  const nextThinking = this.resolveThinkingForModel(modelOption, selectedThinkingLevel);
@@ -22,7 +22,10 @@ import {
22
22
  } from "@/components/chat/chat-session-route";
23
23
  import { useNcpChatPageData } from "@/components/chat/ncp/ncp-chat-page-data";
24
24
  import { NcpChatPresenter } from "@/components/chat/ncp/ncp-chat.presenter";
25
- import { useNcpSessionConversation } from "@/components/chat/ncp/session-conversation/use-ncp-session-conversation";
25
+ import {
26
+ isNcpAgentStartupUnavailableErrorMessage,
27
+ useNcpSessionConversation,
28
+ } from "@/components/chat/ncp/session-conversation/use-ncp-session-conversation";
26
29
  import { useNcpChatDerivedState, useNcpChatSnapshotSync } from "@/components/chat/ncp/page/ncp-chat-derived-state";
27
30
  import { ChatPresenterProvider } from "@/components/chat/presenter/chat-presenter-context";
28
31
  import type { ResumeRunParams } from "@/components/chat/chat-stream/types";
@@ -31,6 +34,10 @@ import { useChatSessionListStore } from "@/components/chat/stores/chat-session-l
31
34
  import { useConfirmDialog } from "@/hooks/useConfirmDialog";
32
35
  import { useAgents } from "@/hooks/agents/useAgents";
33
36
  import { normalizeRequestedSkills } from "@/lib/chat-runtime-utils";
37
+ import {
38
+ systemStatusManager,
39
+ useChatRuntimeAvailability,
40
+ } from "@/features/system-status";
34
41
  import {
35
42
  getSessionProjectName,
36
43
  normalizeSessionProjectRootValue,
@@ -119,6 +126,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
119
126
  const pendingProjectRootSessionKey = useChatInputStore(
120
127
  (state) => state.snapshot.pendingProjectRootSessionKey,
121
128
  );
129
+ const runtimeAvailability = useChatRuntimeAvailability();
122
130
  const agentsQuery = useAgents();
123
131
  const currentSelectedModel = useChatInputStore(
124
132
  (state) => state.snapshot.selectedModel,
@@ -182,8 +190,19 @@ export function NcpChatPage({ view }: ChatPageProps) {
182
190
  const isAwaitingAssistantOutput = agent.isRunning;
183
191
  const canStopCurrentRun = agent.isRunning;
184
192
  const stopDisabledReason = agent.isRunning ? null : "__preparing__";
185
- const lastSendError =
193
+ const rawLastSendError =
186
194
  agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
195
+ const filteredLastSendError =
196
+ runtimeAvailability.phase === "ready" &&
197
+ isNcpAgentStartupUnavailableErrorMessage(rawLastSendError)
198
+ ? null
199
+ : rawLastSendError;
200
+ const lastSendError =
201
+ runtimeAvailability.isBlocked
202
+ ? null
203
+ : runtimeAvailability.phase === "ready"
204
+ ? filteredLastSendError
205
+ : systemStatusManager.getDisplayMessage(filteredLastSendError);
187
206
 
188
207
  useEffect(() => {
189
208
  presenter.chatStreamActionsManager.bind({
@@ -1,16 +1,20 @@
1
- import { renderHook } from "@testing-library/react";
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { fetchNcpSessionConversationSeed, useNcpSessionConversation } from "./use-ncp-session-conversation";
4
4
 
5
5
  const mocks = vi.hoisted(() => ({
6
6
  fetchNcpSessionMessages: vi.fn(),
7
- hydratedCalls: [] as Array<{ client: unknown }>,
7
+ hydratedCalls: [] as Array<{ client: unknown; loadSeed: unknown }>,
8
+ runtimeAvailability: {
9
+ phase: "cold-starting" as "cold-starting" | "ready",
10
+ lastReadyAt: null as number | null,
11
+ },
8
12
  useHydratedNcpAgent: vi.fn(() => ({
9
13
  snapshot: {
10
14
  messages: [],
11
15
  streamingMessage: null,
12
16
  activeRun: null,
13
- error: null,
17
+ error: null as Error | null,
14
18
  },
15
19
  visibleMessages: [],
16
20
  activeRunId: null,
@@ -30,7 +34,7 @@ vi.mock("@/api/ncp-session", () => ({
30
34
  }));
31
35
 
32
36
  vi.mock("@nextclaw/ncp-react", () => ({
33
- useHydratedNcpAgent: vi.fn((params: { client: unknown }) => {
37
+ useHydratedNcpAgent: vi.fn((params: { client: unknown; loadSeed: unknown }) => {
34
38
  mocks.hydratedCalls.push(params);
35
39
  return mocks.useHydratedNcpAgent();
36
40
  }),
@@ -42,12 +46,18 @@ vi.mock("@nextclaw/ncp-http-agent-client", () => ({
42
46
  }),
43
47
  }));
44
48
 
49
+ vi.mock("@/features/system-status", () => ({
50
+ useChatRuntimeAvailability: vi.fn(() => mocks.runtimeAvailability),
51
+ }));
52
+
45
53
  describe("useNcpSessionConversation", () => {
46
54
  beforeEach(() => {
47
55
  mocks.fetchNcpSessionMessages.mockReset();
48
56
  mocks.useHydratedNcpAgent.mockClear();
49
57
  mocks.hydratedCalls.length = 0;
50
58
  mocks.clientInstances.length = 0;
59
+ mocks.runtimeAvailability.phase = "cold-starting";
60
+ mocks.runtimeAvailability.lastReadyAt = null;
51
61
  });
52
62
 
53
63
  it("hydrates seed from the shared session messages endpoint payload", async () => {
@@ -98,4 +108,38 @@ describe("useNcpSessionConversation", () => {
98
108
  expect(mocks.hydratedCalls[0]?.client).toBe(mocks.clientInstances[0]);
99
109
  expect(mocks.hydratedCalls[1]?.client).toBe(mocks.clientInstances[1]);
100
110
  });
111
+
112
+ it("retries hydration once the runtime becomes ready after a startup placeholder error", async () => {
113
+ mocks.useHydratedNcpAgent.mockImplementation(() => ({
114
+ snapshot: {
115
+ messages: [],
116
+ streamingMessage: null,
117
+ activeRun: null,
118
+ error: new Error("ncp agent unavailable during startup") as Error | null,
119
+ },
120
+ visibleMessages: [],
121
+ activeRunId: null,
122
+ isRunning: false,
123
+ isSending: false,
124
+ send: vi.fn(),
125
+ abort: vi.fn(),
126
+ streamRun: vi.fn(),
127
+ isHydrating: false,
128
+ hydrateError: null,
129
+ }));
130
+
131
+ const { rerender } = renderHook(() => useNcpSessionConversation("session-a"));
132
+ const initialLoadSeed = mocks.hydratedCalls[0]?.loadSeed;
133
+
134
+ act(() => {
135
+ mocks.runtimeAvailability.phase = "ready";
136
+ mocks.runtimeAvailability.lastReadyAt = 123;
137
+ });
138
+ rerender();
139
+
140
+ await waitFor(() => {
141
+ expect(mocks.hydratedCalls.length).toBeGreaterThan(2);
142
+ });
143
+ expect(mocks.hydratedCalls[mocks.hydratedCalls.length - 1]?.loadSeed).not.toBe(initialLoadSeed);
144
+ });
101
145
  });
@@ -1,11 +1,13 @@
1
- import { useCallback, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { NcpHttpAgentClientEndpoint } from "@nextclaw/ncp-http-agent-client";
3
3
  import { useHydratedNcpAgent, type NcpConversationSeed } from "@nextclaw/ncp-react";
4
4
  import { API_BASE } from "@/api/api-base";
5
5
  import { fetchNcpSessionMessages } from "@/api/ncp-session";
6
6
  import { createNcpAppClientFetch } from "@/components/chat/ncp/ncp-app-client-fetch";
7
+ import { useChatRuntimeAvailability } from "@/features/system-status";
7
8
 
8
9
  const DEFAULT_MESSAGE_LIMIT = 300;
10
+ const NCP_AGENT_UNAVAILABLE_DURING_STARTUP = "ncp agent unavailable during startup";
9
11
 
10
12
  type UseNcpSessionConversationOptions = {
11
13
  messageLimit?: number;
@@ -18,6 +20,15 @@ function isMissingNcpSessionError(error: unknown): boolean {
18
20
  return error.message.includes("ncp session not found:");
19
21
  }
20
22
 
23
+ export function isNcpAgentStartupUnavailableErrorMessage(
24
+ message: string | null | undefined,
25
+ ): boolean {
26
+ return (
27
+ message?.trim().toLowerCase().includes(NCP_AGENT_UNAVAILABLE_DURING_STARTUP) ??
28
+ false
29
+ );
30
+ }
31
+
21
32
  export function createNcpSessionConversationClient(): NcpHttpAgentClientEndpoint {
22
33
  return new NcpHttpAgentClientEndpoint({
23
34
  baseUrl: API_BASE,
@@ -57,16 +68,43 @@ export function useNcpSessionConversation(
57
68
  options: UseNcpSessionConversationOptions = {},
58
69
  ) {
59
70
  const [client] = useState(() => createNcpSessionConversationClient());
71
+ const runtimeAvailability = useChatRuntimeAvailability();
72
+ const [hydrationRetryNonce, setHydrationRetryNonce] = useState(0);
73
+ const retriedReadySignatureRef = useRef<string | null>(null);
60
74
  const messageLimit = options.messageLimit ?? DEFAULT_MESSAGE_LIMIT;
61
75
  const loadSeed = useCallback(
62
- (targetSessionId: string, signal: AbortSignal) =>
63
- fetchNcpSessionConversationSeed(targetSessionId, signal, messageLimit),
64
- [messageLimit],
76
+ (targetSessionId: string, signal: AbortSignal) => {
77
+ void hydrationRetryNonce;
78
+ return fetchNcpSessionConversationSeed(targetSessionId, signal, messageLimit);
79
+ },
80
+ [hydrationRetryNonce, messageLimit],
65
81
  );
66
82
 
67
- return useHydratedNcpAgent({
83
+ const agent = useHydratedNcpAgent({
68
84
  sessionId,
69
85
  client,
70
86
  loadSeed,
71
87
  });
88
+
89
+ const currentAgentError =
90
+ agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
91
+ const readyRetrySignature =
92
+ runtimeAvailability.phase === "ready" &&
93
+ isNcpAgentStartupUnavailableErrorMessage(currentAgentError)
94
+ ? `${sessionId}:${runtimeAvailability.lastReadyAt ?? 0}`
95
+ : null;
96
+
97
+ useEffect(() => {
98
+ if (!readyRetrySignature) {
99
+ retriedReadySignatureRef.current = null;
100
+ return;
101
+ }
102
+ if (retriedReadySignatureRef.current === readyRetrySignature) {
103
+ return;
104
+ }
105
+ retriedReadySignatureRef.current = readyRetrySignature;
106
+ setHydrationRetryNonce((current) => current + 1);
107
+ }, [readyRetrySignature]);
108
+
109
+ return agent;
72
110
  }