@nextclaw/ui 0.12.9 → 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 (245) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/assets/ChannelsList-SQ7Oxotv.js +8 -0
  3. package/dist/assets/DocBrowser-BCO2k6XD.js +1 -0
  4. package/dist/assets/{DocBrowser-6ReNjvzF.js → DocBrowser-rDOjI3ga.js} +1 -1
  5. package/dist/assets/{DocBrowserContext-B6SpA7Qs.js → DocBrowserContext-BUq3Wo8O.js} +1 -1
  6. package/dist/assets/{LogoBadge-ByNLYg65.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-ChHQ7M5c.js → SessionsConfig-CvjxU40H.js} +2 -2
  14. package/dist/assets/{book-open-BdcxxoQu.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-DK5HPmIK.js → chunk-JZWAC4HX-erTUn3b8.js} +1 -1
  18. package/dist/assets/client-CszWMVKi.js +7 -0
  19. package/dist/assets/config-split-page-BAGSzUR3.js +1 -0
  20. package/dist/assets/{createLucideIcon-BSeTgkZW.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-6TrrnPCR.js → dist-Dd9cr-kz.js} +1 -1
  25. package/dist/assets/dist-ZwoAXs46.js +9 -0
  26. package/dist/assets/{download-BhDxnyvU.js → download-D7LOizcW.js} +1 -1
  27. package/dist/assets/es2015-CEAreese.js +41 -0
  28. package/dist/assets/{external-link-BgErLCNT.js → external-link-qsnCMhw1.js} +1 -1
  29. package/dist/assets/{hash-Bl7dr_UG.js → hash-0zjWsNl-.js} +1 -1
  30. package/dist/assets/{i18n-eDHeDY0n.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-x89HbrZ4.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-C47QSEwg.js → refresh-cw-MNqgR3LZ.js} +1 -1
  42. package/dist/assets/remote-C9fXm4V5.js +1 -0
  43. package/dist/assets/{save-3S6-H3Xw.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-BsLtHOH-.js → switch-CBOzecWS.js} +1 -1
  50. package/dist/assets/{tabs-custom-D3HYMt6k.js → tabs-custom-Bx3cNhD-.js} +1 -1
  51. package/dist/assets/tag-chip-zUaDE2-H.js +1 -0
  52. package/dist/assets/{trash-2-G48scll7.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-CBWjE2uj.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/dist/runtime-icons/claude.ico +0 -0
  59. package/dist/runtime-icons/codex-openai.svg +6 -0
  60. package/dist/runtime-icons/hermes-agent.png +0 -0
  61. package/module-structure.config.json +7 -0
  62. package/package.json +6 -6
  63. package/public/runtime-icons/claude.ico +0 -0
  64. package/public/runtime-icons/codex-openai.svg +6 -0
  65. package/public/runtime-icons/hermes-agent.png +0 -0
  66. package/src/api/chat-session-type.types.ts +7 -0
  67. package/src/api/config.ts +10 -0
  68. package/src/api/raw-client.test.ts +1 -1
  69. package/src/api/{raw-client.ts → raw-client.utils.ts} +2 -0
  70. package/src/api/runtime-control.types.ts +8 -0
  71. package/src/api/types.ts +48 -0
  72. package/src/app/components/app-manager-provider.tsx +20 -0
  73. package/src/app/managers/app.manager.ts +12 -0
  74. package/src/app.tsx +223 -59
  75. package/src/components/agents/agent-dialogs.tsx +499 -0
  76. package/src/components/agents/agents-page.test.tsx +238 -0
  77. package/src/components/agents/agents-page.tsx +435 -0
  78. package/src/components/chat/chat-conversation-panel.test.tsx +30 -0
  79. package/src/components/chat/chat-conversation-panel.tsx +83 -13
  80. package/src/components/chat/chat-input/ncp-chat-input-availability.utils.test.ts +92 -0
  81. package/src/components/chat/chat-input/ncp-chat-input-availability.utils.ts +45 -0
  82. package/src/components/chat/chat-page-shell.tsx +19 -13
  83. package/src/components/chat/chat-session-type-option-item.test.tsx +46 -0
  84. package/src/components/chat/chat-session-type-option-item.tsx +68 -0
  85. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +87 -0
  86. package/src/components/chat/chat-session-workspace-file-preview.tsx +14 -43
  87. package/src/components/chat/chat-session-workspace-panel-nav.tsx +8 -2
  88. package/src/components/chat/chat-sidebar-project-groups.tsx +11 -36
  89. package/src/components/chat/containers/chat-input-bar.container.tsx +24 -12
  90. package/src/components/chat/{ChatSidebar.test.tsx → containers/chat-sidebar.test.tsx} +5 -4
  91. package/src/components/chat/{ChatSidebar.tsx → containers/chat-sidebar.tsx} +24 -72
  92. package/src/components/chat/hooks/use-chat-sidebar-session-label-editor.ts +49 -0
  93. package/src/components/chat/ncp/__tests__/ncp-session-adapter.cancelled-tool.test.ts +77 -0
  94. package/src/components/chat/ncp/ncp-app-client-fetch.ts +3 -0
  95. package/src/components/chat/ncp/ncp-chat-input.manager.ts +13 -5
  96. package/src/components/chat/ncp/ncp-chat-page.tsx +23 -2
  97. package/src/components/chat/ncp/ncp-session-adapter.test.ts +1 -0
  98. package/src/components/chat/ncp/ncp-session-adapter.ts +3 -0
  99. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +10 -4
  100. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +48 -4
  101. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +43 -5
  102. package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +51 -1
  103. package/src/components/chat/stores/chat-input.store.ts +2 -1
  104. package/src/components/chat/stores/chat-thread.store.ts +3 -1
  105. package/src/components/chat/useChatSessionTypeState.ts +10 -1
  106. package/src/components/chat/workspace/chat-session-workspace-file-breadcrumbs.tsx +86 -0
  107. package/src/components/common/BrandHeader.tsx +3 -1
  108. package/src/components/common/session-context-icon.tsx +15 -2
  109. package/src/components/common/{TagInput.tsx → tag-input.tsx} +25 -17
  110. package/src/components/config/ChannelForm.test.tsx +89 -3
  111. package/src/components/config/ChannelForm.tsx +157 -188
  112. package/src/components/config/ChannelsList.test.tsx +163 -119
  113. package/src/components/config/ChannelsList.tsx +90 -101
  114. package/src/components/config/ProviderForm.tsx +108 -146
  115. package/src/components/config/ProvidersList.tsx +100 -123
  116. package/src/components/config/SearchConfig.tsx +423 -393
  117. package/src/components/config/channel-form-fields-section.tsx +70 -37
  118. package/src/components/config/config-split-page.tsx +109 -0
  119. package/src/components/config/desktop-update-config.test.tsx +10 -4
  120. package/src/components/config/desktop-update-config.tsx +5 -3
  121. package/src/components/config/provider-enabled-field.tsx +17 -10
  122. package/src/components/config/runtime-control-card.test.tsx +136 -158
  123. package/src/components/config/runtime-control-card.tsx +43 -68
  124. package/src/components/config/runtime-presence-card.test.tsx +10 -14
  125. package/src/components/config/runtime-presence-card.tsx +97 -81
  126. package/src/components/layout/AppLayout.tsx +25 -37
  127. package/src/components/layout/Sidebar.tsx +4 -4
  128. package/src/components/layout/app-layout.test.tsx +46 -14
  129. package/src/components/layout/runtime-status-entry.test.tsx +101 -0
  130. package/src/components/layout/runtime-status-entry.tsx +95 -0
  131. package/src/components/layout/sidebar.layout.test.tsx +11 -5
  132. package/src/components/marketplace/marketplace-detail-doc.ts +93 -0
  133. package/src/components/marketplace/marketplace-list-card.tsx +288 -0
  134. package/src/components/marketplace/marketplace-page-data.ts +129 -0
  135. package/src/components/marketplace/marketplace-page.test.tsx +339 -0
  136. package/src/components/marketplace/marketplace-page.tsx +596 -0
  137. package/src/components/marketplace/mcp/mcp-marketplace-card.tsx +128 -0
  138. package/src/components/marketplace/mcp/mcp-marketplace-dialogs.tsx +191 -0
  139. package/src/components/marketplace/mcp/mcp-marketplace-doc.ts +152 -0
  140. package/src/components/marketplace/mcp/mcp-marketplace-page.test.tsx +223 -0
  141. package/src/components/marketplace/mcp/mcp-marketplace-page.tsx +414 -0
  142. package/src/components/ui/notice-card.tsx +129 -0
  143. package/src/components/ui/setting-row.tsx +51 -0
  144. package/src/components/ui/tag-chip.tsx +39 -0
  145. package/src/components/ui/textarea.tsx +19 -0
  146. package/src/features/account/components/account-panel.tsx +255 -0
  147. package/src/features/account/index.ts +6 -0
  148. package/src/{account → features/account}/managers/account.manager.ts +6 -5
  149. package/src/features/remote/components/remote-access-page.test.tsx +104 -0
  150. package/src/features/remote/components/remote-access-page.tsx +250 -0
  151. package/src/{hooks/useRemoteAccess.ts → features/remote/hooks/use-remote-access.ts} +1 -1
  152. package/src/features/remote/index.ts +27 -0
  153. package/src/{remote → features/remote}/managers/remote-access.manager.ts +3 -4
  154. package/src/{remote → features/remote/services}/remote-access-feedback.service.test.ts +1 -1
  155. package/src/features/system-status/hooks/use-system-status.ts +104 -0
  156. package/src/features/system-status/index.ts +12 -0
  157. package/src/features/system-status/managers/system-status.manager.bootstrap-polling.test.ts +126 -0
  158. package/src/features/system-status/managers/system-status.manager.test.ts +142 -0
  159. package/src/features/system-status/managers/system-status.manager.ts +511 -0
  160. package/src/features/system-status/stores/system-status.store.ts +32 -0
  161. package/src/features/system-status/types/system-status.types.ts +73 -0
  162. package/src/features/system-status/utils/system-status.utils.test.ts +132 -0
  163. package/src/features/system-status/utils/system-status.utils.ts +202 -0
  164. package/src/hooks/use-realtime-query-bridge.ts +34 -18
  165. package/src/hooks/useConfig.ts +2 -1
  166. package/src/index.css +24 -0
  167. package/src/lib/app-resource-uri.test.ts +20 -0
  168. package/src/lib/app-resource-uri.ts +29 -0
  169. package/src/lib/i18n.chat.ts +8 -0
  170. package/src/lib/i18n.remote.ts +1 -1
  171. package/src/lib/i18n.runtime-control.ts +31 -0
  172. package/src/lib/i18n.ts +5 -8
  173. package/src/lib/session-context.utils.test.ts +71 -0
  174. package/src/lib/session-context.utils.ts +28 -3
  175. package/src/lib/session-project/workspace-file-breadcrumb.test.ts +83 -0
  176. package/src/lib/session-project/workspace-file-breadcrumb.ts +188 -0
  177. package/src/platforms/desktop/index.ts +20 -0
  178. package/src/{desktop → platforms/desktop}/managers/desktop-presence.manager.ts +2 -2
  179. package/src/{desktop → platforms/desktop}/managers/desktop-update.manager.ts +2 -2
  180. package/src/{desktop → platforms/desktop}/stores/desktop-presence.store.ts +1 -1
  181. package/src/{desktop → platforms/desktop}/stores/desktop-update.store.ts +1 -1
  182. package/src/stores/ui.store.ts +0 -9
  183. package/src/transport/{app-client.ts → app-client.service.ts} +9 -9
  184. package/src/transport/app-client.test.ts +9 -5
  185. package/src/transport/index.ts +1 -1
  186. package/src/transport/{local.transport.ts → local-transport.service.ts} +14 -12
  187. package/dist/assets/ChannelsList-Ita2Zm1_.js +0 -8
  188. package/dist/assets/DocBrowser-BNwbPHf4.js +0 -1
  189. package/dist/assets/MarketplacePage-CjX2MWww.js +0 -1
  190. package/dist/assets/MarketplacePage-D0sDlYX4.js +0 -49
  191. package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +0 -40
  192. package/dist/assets/ModelConfig-BzZenCH-.js +0 -1
  193. package/dist/assets/ProviderScopedModelInput-Da7khnBA.js +0 -1
  194. package/dist/assets/ProvidersList-BbVzRxjY.js +0 -1
  195. package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +0 -1
  196. package/dist/assets/RuntimeConfig-F_XKGgLm.js +0 -1
  197. package/dist/assets/SearchConfig-BGkzXQP-.js +0 -1
  198. package/dist/assets/SecretsConfig-D281Rotl.js +0 -3
  199. package/dist/assets/app-query-client-VnFElj4E.js +0 -1
  200. package/dist/assets/chat-page-Doe0yTtB.js +0 -58
  201. package/dist/assets/chat-session-display-cw78aiI_.js +0 -1
  202. package/dist/assets/client-_i4MU2bB.js +0 -7
  203. package/dist/assets/config-DtIQwrHF.js +0 -1
  204. package/dist/assets/config-layout-CHs0mAaR.js +0 -1
  205. package/dist/assets/desktop-update-config-Dpcf4BKG.js +0 -1
  206. package/dist/assets/dist-ccBFUi-o.js +0 -9
  207. package/dist/assets/index-CF9xve0E.js +0 -6
  208. package/dist/assets/index-FgA52VBt.css +0 -1
  209. package/dist/assets/infiniteQueryBehavior-ZDS92Qpp.js +0 -1
  210. package/dist/assets/loader-circle-ACM1s51e.js +0 -1
  211. package/dist/assets/page-layout-vZnghcFy.js +0 -1
  212. package/dist/assets/play-CFUwCA2E.js +0 -1
  213. package/dist/assets/plus-rYsv72JG.js +0 -1
  214. package/dist/assets/popover-Bg1VoTZ6.js +0 -1
  215. package/dist/assets/refresh-ccw-DT98i__E.js +0 -1
  216. package/dist/assets/rotate-cw-JtFzpNn6.js +0 -1
  217. package/dist/assets/search-3kFR_zh9.js +0 -1
  218. package/dist/assets/security-config-BWaiARNk.js +0 -1
  219. package/dist/assets/select-DJ2MUjBB.js +0 -41
  220. package/dist/assets/skeleton-ByQepn0M.js +0 -1
  221. package/dist/assets/status-dot-vbanNPFU.js +0 -1
  222. package/dist/assets/use-infinite-scroll-loader-DkNhD-42.js +0 -1
  223. package/dist/assets/useConfirmDialog-BkvTN-vd.js +0 -1
  224. package/dist/assets/x-ByDbItbq.js +0 -1
  225. package/src/account/components/account-panel.tsx +0 -135
  226. package/src/components/agents/AgentDialogs.tsx +0 -400
  227. package/src/components/agents/AgentsPage.test.tsx +0 -217
  228. package/src/components/agents/AgentsPage.tsx +0 -352
  229. package/src/components/config/config-layout.ts +0 -10
  230. package/src/components/marketplace/MarketplacePage.test.tsx +0 -322
  231. package/src/components/marketplace/MarketplacePage.tsx +0 -827
  232. package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +0 -208
  233. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +0 -580
  234. package/src/components/remote/RemoteAccessPage.test.tsx +0 -103
  235. package/src/components/remote/RemoteAccessPage.tsx +0 -144
  236. package/src/hooks/use-runtime-control.ts +0 -24
  237. package/src/presenter/app-presenter-context.tsx +0 -20
  238. package/src/presenter/app.presenter.ts +0 -12
  239. package/src/runtime-control/runtime-control.manager.ts +0 -118
  240. /package/dist/assets/{config-hints-BhTmc9P1.js → config-hints-DSQQbeOA.js} +0 -0
  241. /package/src/{account → features/account}/stores/account.store.ts +0 -0
  242. /package/src/{remote → features/remote/services}/remote-access-feedback.service.ts +0 -0
  243. /package/src/{remote/remote-access.query.ts → features/remote/services/remote-access-query.service.ts} +0 -0
  244. /package/src/{remote → features/remote}/stores/remote-access.store.ts +0 -0
  245. /package/src/{desktop → platforms/desktop/types}/desktop-update.types.ts +0 -0
@@ -8,12 +8,14 @@ import {
8
8
  FileOperationCodeSurface,
9
9
  } from "@nextclaw/agent-chat-ui";
10
10
  import type { ChatWorkspaceFileTab } from "@/components/chat/stores/chat-thread.store";
11
+ import { ChatSessionWorkspaceFileBreadcrumbs } from "@/components/chat/workspace/chat-session-workspace-file-breadcrumbs";
11
12
  import { useServerPathRead } from "@/hooks/server-path/use-server-path-read";
12
13
  import {
13
14
  buildLineDiff,
14
15
  buildPreviewLines,
15
16
  } from "@/components/chat/adapters/file-operation/line-builder";
16
17
  import { t } from "@/lib/i18n";
18
+ import { buildWorkspaceFileBreadcrumb } from "@/lib/session-project/workspace-file-breadcrumb";
17
19
  import { cn } from "@/lib/utils";
18
20
 
19
21
  function inferPreviewKind(params: {
@@ -118,44 +120,6 @@ function WorkspaceFilePreviewStatus({
118
120
  );
119
121
  }
120
122
 
121
- function WorkspaceFileHeader({
122
- file,
123
- resolvedPath,
124
- truncated,
125
- }: {
126
- file: ChatWorkspaceFileTab;
127
- resolvedPath: string;
128
- truncated: boolean;
129
- }) {
130
- return (
131
- <div className="border-b border-gray-200/80 px-4 py-3">
132
- <div
133
- title={resolvedPath}
134
- className="truncate font-mono text-[12px] font-medium text-gray-700"
135
- >
136
- {resolvedPath}
137
- </div>
138
- <div className="mt-2 flex flex-wrap items-center gap-2">
139
- <span className="rounded border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-gray-500">
140
- {file.viewMode === "diff"
141
- ? t("chatWorkspaceDiff")
142
- : t("chatWorkspacePreview")}
143
- </span>
144
- {typeof file.line === "number" ? (
145
- <span className="rounded border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-500">
146
- {`L${file.line}${typeof file.column === "number" ? `:${file.column}` : ""}`}
147
- </span>
148
- ) : null}
149
- {truncated ? (
150
- <span className="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
151
- {t("chatWorkspacePreviewTruncated")}
152
- </span>
153
- ) : null}
154
- </div>
155
- </div>
156
- );
157
- }
158
-
159
123
  function WorkspaceDiffBody({
160
124
  diffBlock,
161
125
  }: {
@@ -280,14 +244,21 @@ export function ChatSessionWorkspaceFilePreview({
280
244
  }, [file.line, file.path, isPreviewMode, previewQuery.data?.resolvedPath, previewText]);
281
245
  const resolvedPath = previewQuery.data?.resolvedPath ?? file.path;
282
246
  const isTruncated = Boolean(previewQuery.data?.truncated);
247
+ const breadcrumb = useMemo(
248
+ () =>
249
+ buildWorkspaceFileBreadcrumb({
250
+ path: resolvedPath,
251
+ sessionProjectRoot,
252
+ line: file.line,
253
+ column: file.column,
254
+ truncated: isTruncated,
255
+ }),
256
+ [file.column, file.line, isTruncated, resolvedPath, sessionProjectRoot],
257
+ );
283
258
 
284
259
  return (
285
260
  <div className="flex h-full min-h-0 flex-col bg-white">
286
- <WorkspaceFileHeader
287
- file={file}
288
- resolvedPath={resolvedPath}
289
- truncated={isTruncated}
290
- />
261
+ <ChatSessionWorkspaceFileBreadcrumbs breadcrumb={breadcrumb} />
291
262
 
292
263
  <div className="flex-1 min-h-0 overflow-hidden">
293
264
  {file.viewMode === "diff" ? (
@@ -186,8 +186,14 @@ export function WorkspaceTabsBar({
186
186
  tabs: readonly WorkspaceTabViewModel[];
187
187
  }) {
188
188
  return (
189
- <div className="overflow-x-auto border-b border-gray-200/70 bg-gray-50/85 custom-scrollbar">
190
- <div className="flex min-w-max items-stretch">
189
+ <div
190
+ data-testid="workspace-tabs-bar"
191
+ className="workspace-horizontal-scrollbar overflow-x-auto overflow-y-hidden border-b border-gray-200/70 bg-gray-50/85"
192
+ >
193
+ <div
194
+ data-testid="workspace-tabs-scroll"
195
+ className="flex min-w-max items-stretch"
196
+ >
191
197
  {tabs.map((tab) => (
192
198
  <WorkspaceTabItem key={tab.key} tab={tab} />
193
199
  ))}
@@ -1,11 +1,11 @@
1
1
  import { useMemo, useState, type ReactNode } from 'react';
2
2
  import { Plus } from 'lucide-react';
3
3
  import { Button } from '@/components/ui/button';
4
+ import { ChatSessionTypeOptionItem } from '@/components/chat/chat-session-type-option-item';
4
5
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
5
6
  import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
6
7
  import type { NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
7
8
  import { t } from '@/lib/i18n';
8
- import { cn } from '@/lib/utils';
9
9
 
10
10
  export type ChatSidebarProjectGroup = {
11
11
  projectRoot: string;
@@ -34,16 +34,6 @@ function resolveProjectGroupDefaultSessionType(
34
34
  return sessionTypeOptions[0]?.value ?? defaultSessionType;
35
35
  }
36
36
 
37
- function resolveSessionTypeStatusText(option: {
38
- ready?: boolean;
39
- reasonMessage?: string | null;
40
- }): string {
41
- if (option.ready === false) {
42
- return option.reasonMessage?.trim() || t('statusSetup');
43
- }
44
- return t('statusReady');
45
- }
46
-
47
37
  export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
48
38
  const { groups, defaultSessionType, sessionTypeOptions, renderSessionItem, onCreateSession } = props;
49
39
  const [openProjectRoot, setOpenProjectRoot] = useState<string | null>(null);
@@ -91,38 +81,23 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
91
81
  <Plus className="h-3.5 w-3.5" />
92
82
  </Button>
93
83
  </PopoverTrigger>
94
- <PopoverContent align="end" className="w-64 p-2">
95
- <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-gray-400">
84
+ <PopoverContent
85
+ align="end"
86
+ className="w-56 rounded-2xl border border-gray-200/80 bg-white p-1.5 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.38)]"
87
+ >
88
+ <div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-400">
96
89
  {t('chatSessionTypeLabel')}
97
90
  </div>
98
- <div className="mt-1 space-y-1">
91
+ <div className="space-y-1">
99
92
  {sessionTypeOptions.map((option) => (
100
- <button
93
+ <ChatSessionTypeOptionItem
101
94
  key={`${group.projectRoot}:${option.value}`}
102
- type="button"
103
- onClick={() => {
95
+ option={option}
96
+ onSelect={() => {
104
97
  onCreateSession(option.value, group.projectRoot);
105
98
  setOpenProjectRoot(null);
106
99
  }}
107
- className="w-full rounded-xl px-3 py-2 text-left transition-colors hover:bg-gray-100"
108
- >
109
- <div className="flex items-center justify-between gap-3">
110
- <div className="text-[13px] font-medium text-gray-900">{option.label}</div>
111
- <span
112
- className={cn(
113
- 'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold',
114
- option.ready === false
115
- ? 'bg-amber-100 text-amber-800'
116
- : 'bg-emerald-100 text-emerald-700'
117
- )}
118
- >
119
- {option.ready === false ? t('statusSetup') : t('statusReady')}
120
- </span>
121
- </div>
122
- <div className="mt-0.5 text-[11px] text-gray-500">
123
- {resolveSessionTypeStatusText(option)}
124
- </div>
125
- </button>
100
+ />
126
101
  ))}
127
102
  </div>
128
103
  </PopoverContent>
@@ -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() {
@@ -7,13 +7,14 @@ import { Input } from '@/components/ui/input';
7
7
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
8
8
  import { SelectItem } from '@/components/ui/select';
9
9
  import { ChatSidebarSessionItem } from '@/components/chat/chat-sidebar-session-item';
10
+ import { ChatSessionTypeOptionItem } from '@/components/chat/chat-session-type-option-item';
10
11
  import { ChatSidebarListModeSwitch } from '@/components/chat/chat-sidebar-list-mode-switch';
11
12
  import {
12
13
  ChatSidebarProjectGroups,
13
14
  type ChatSidebarProjectGroup
14
15
  } from '@/components/chat/chat-sidebar-project-groups';
15
16
  import { resolveSessionContextView } from '@/lib/session-context.utils';
16
- import { useChatSessionLabel } from '@/components/chat/hooks/use-chat-session-label';
17
+ import { useChatSidebarSessionLabelEditor } from '@/components/chat/hooks/use-chat-sidebar-session-label-editor';
17
18
  import { useNcpSessionListView, type NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
18
19
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
19
20
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
@@ -30,7 +31,7 @@ import { useI18n } from '@/components/providers/I18nProvider';
30
31
  import { useTheme } from '@/components/providers/ThemeProvider';
31
32
  import { useDocBrowser } from '@/components/doc-browser';
32
33
  import { SidebarActionItem, SidebarNavLinkItem, SidebarSelectItem } from '@/components/layout/sidebar-items';
33
- import { useUiStore } from '@/stores/ui.store';
34
+ import { useSystemStatus } from '@/features/system-status';
34
35
  import {
35
36
  AlarmClock,
36
37
  Bot,
@@ -130,16 +131,6 @@ function sessionTitle(session: SessionEntryView): string {
130
131
  return chunks[chunks.length - 1] || session.key;
131
132
  }
132
133
 
133
- function resolveSessionTypeStatusText(option: {
134
- ready?: boolean;
135
- reasonMessage?: string | null;
136
- }): string {
137
- if (option.ready === false) {
138
- return option.reasonMessage?.trim() || t('statusSetup');
139
- }
140
- return t('statusReady');
141
- }
142
-
143
134
  const navItems = [
144
135
  { target: '/cron', label: () => t('chatSidebarScheduledTasks'), icon: AlarmClock },
145
136
  { target: '/skills', label: () => t('chatSidebarSkills'), icon: BrainCircuit },
@@ -178,22 +169,17 @@ function useChatSessionUnreadState(
178
169
 
179
170
  return optimisticReadAtBySessionKey;
180
171
  }
181
-
182
172
  export function ChatSidebar() {
183
173
  const presenter = usePresenter();
184
174
  const docBrowser = useDocBrowser();
185
175
  const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
186
- const [editingSessionKey, setEditingSessionKey] = useState<string | null>(null);
187
- const [draftLabel, setDraftLabel] = useState('');
188
- const [savingSessionKey, setSavingSessionKey] = useState<string | null>(null);
189
176
  const inputSnapshot = useChatInputStore((state) => state.snapshot);
190
177
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
191
- const connectionStatus = useUiStore((state) => state.connectionStatus);
178
+ const systemStatus = useSystemStatus();
192
179
  const agentsQuery = useAgents();
193
180
  const { isLoading, items } = useNcpSessionListView();
194
181
  const { language, setLanguage } = useI18n();
195
182
  const { theme, setTheme } = useTheme();
196
- const updateSessionLabel = useChatSessionLabel();
197
183
  const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
198
184
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
199
185
  const agentsById = useMemo(
@@ -230,39 +216,20 @@ export function ChatSidebar() {
230
216
  listSnapshot.selectedSessionKey,
231
217
  presenter.chatSessionListManager.markSessionRead,
232
218
  );
219
+ const {
220
+ editingSessionKey,
221
+ draftLabel,
222
+ savingSessionKey,
223
+ setDraftLabel,
224
+ startEditingSessionLabel,
225
+ cancelEditingSessionLabel,
226
+ saveSessionLabel,
227
+ } = useChatSidebarSessionLabelEditor();
233
228
  const handleLanguageSwitch = (nextLang: I18nLanguage) => {
234
229
  if (language === nextLang) return;
235
230
  setLanguage(nextLang);
236
231
  window.location.reload();
237
232
  };
238
- const startEditingSessionLabel = (session: SessionEntryView) => {
239
- setEditingSessionKey(session.key);
240
- setDraftLabel(session.label?.trim() ?? '');
241
- };
242
- const cancelEditingSessionLabel = () => {
243
- setEditingSessionKey(null);
244
- setDraftLabel('');
245
- setSavingSessionKey(null);
246
- };
247
- const saveSessionLabel = async (session: SessionEntryView) => {
248
- const normalizedLabel = draftLabel.trim();
249
- const currentLabel = session.label?.trim() ?? '';
250
- if (normalizedLabel === currentLabel) {
251
- cancelEditingSessionLabel();
252
- return;
253
- }
254
-
255
- setSavingSessionKey(session.key);
256
- try {
257
- await updateSessionLabel({
258
- sessionKey: session.key,
259
- label: normalizedLabel || null
260
- });
261
- cancelEditingSessionLabel();
262
- } catch {
263
- setSavingSessionKey(null);
264
- }
265
- };
266
233
  const renderSessionItem = ({ session, runStatus }: NcpSessionListItemView) => {
267
234
  const active = listSnapshot.selectedSessionKey === session.key;
268
235
  const optimisticReadAt = optimisticReadAtBySessionKey[session.key];
@@ -315,7 +282,7 @@ export function ChatSidebar() {
315
282
  <div className="px-5 pt-5 pb-3">
316
283
  <BrandHeader
317
284
  className="flex items-center gap-2.5 min-w-0"
318
- suffix={<StatusBadge status={connectionStatus} />}
285
+ suffix={<StatusBadge status={systemStatus.connectionStatus} />}
319
286
  />
320
287
  </div>
321
288
 
@@ -347,38 +314,23 @@ export function ChatSidebar() {
347
314
  <ChevronDown className="h-4 w-4" />
348
315
  </Button>
349
316
  </PopoverTrigger>
350
- <PopoverContent align="end" className="w-64 p-2">
351
- <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-gray-400">
317
+ <PopoverContent
318
+ align="end"
319
+ className="w-56 rounded-2xl border border-gray-200/80 bg-white p-1.5 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.38)]"
320
+ >
321
+ <div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-400">
352
322
  {t('chatSessionTypeLabel')}
353
323
  </div>
354
- <div className="mt-1 space-y-1">
324
+ <div className="space-y-1">
355
325
  {nonDefaultSessionTypeOptions.map((option) => (
356
- <button
326
+ <ChatSessionTypeOptionItem
357
327
  key={option.value}
358
- type="button"
359
- onClick={() => {
328
+ option={option}
329
+ onSelect={() => {
360
330
  presenter.chatSessionListManager.createSession(option.value);
361
331
  setIsCreateMenuOpen(false);
362
332
  }}
363
- className="w-full rounded-xl px-3 py-2 text-left transition-colors hover:bg-gray-100"
364
- >
365
- <div className="flex items-center justify-between gap-3">
366
- <div className="text-[13px] font-medium text-gray-900">{option.label}</div>
367
- <span
368
- className={cn(
369
- 'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold',
370
- option.ready === false
371
- ? 'bg-amber-100 text-amber-800'
372
- : 'bg-emerald-100 text-emerald-700'
373
- )}
374
- >
375
- {option.ready === false ? t('statusSetup') : t('statusReady')}
376
- </span>
377
- </div>
378
- <div className="mt-0.5 text-[11px] text-gray-500">
379
- {resolveSessionTypeStatusText(option)}
380
- </div>
381
- </button>
333
+ />
382
334
  ))}
383
335
  </div>
384
336
  </PopoverContent>
@@ -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
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { adaptChatMessage } from '@/components/chat/adapters/chat-message.adapter';
3
+ import { adaptNcpMessageToUiMessage } from '../ncp-session-adapter';
4
+
5
+ const texts = {
6
+ roleLabels: {
7
+ user: 'User',
8
+ assistant: 'Assistant',
9
+ tool: 'Tool',
10
+ system: 'System',
11
+ fallback: 'Message',
12
+ },
13
+ reasoningLabel: 'Reasoning',
14
+ toolCallLabel: 'Tool Call',
15
+ toolResultLabel: 'Tool Result',
16
+ toolInputLabel: 'Input',
17
+ toolNoOutputLabel: 'No output',
18
+ toolOutputLabel: 'Output',
19
+ toolStatusPreparingLabel: 'Preparing',
20
+ toolStatusRunningLabel: 'Running',
21
+ toolStatusCompletedLabel: 'Completed',
22
+ toolStatusFailedLabel: 'Failed',
23
+ toolStatusCancelledLabel: 'Cancelled',
24
+ imageAttachmentLabel: 'Image',
25
+ fileAttachmentLabel: 'File',
26
+ unknownPartLabel: 'Unknown',
27
+ };
28
+
29
+ describe('adaptNcpMessageToUiMessage cancelled tools', () => {
30
+ it('renders cancelled tool invocations as cancelled cards', () => {
31
+ const uiMessage = adaptNcpMessageToUiMessage({
32
+ id: 'ncp-message-tool-cancelled-1',
33
+ sessionId: 'ncp-session-1',
34
+ role: 'assistant',
35
+ status: 'final',
36
+ timestamp: '2026-04-01T00:00:00.000Z',
37
+ parts: [
38
+ {
39
+ type: 'tool-invocation',
40
+ toolCallId: 'tool-cancelled-1',
41
+ toolName: 'write_file',
42
+ state: 'cancelled',
43
+ args: JSON.stringify({
44
+ path: 'src/app.ts',
45
+ content: 'hello',
46
+ }),
47
+ },
48
+ ],
49
+ });
50
+
51
+ const adapted = adaptChatMessage(
52
+ {
53
+ id: uiMessage.id,
54
+ role: uiMessage.role,
55
+ meta: {
56
+ timestamp: uiMessage.meta?.timestamp,
57
+ status: uiMessage.meta?.status,
58
+ },
59
+ parts: uiMessage.parts as never,
60
+ },
61
+ {
62
+ formatTimestamp: (value) => value ?? '',
63
+ texts,
64
+ },
65
+ );
66
+
67
+ expect(adapted.parts[0]).toMatchObject({
68
+ type: 'tool-card',
69
+ card: {
70
+ toolName: 'write_file',
71
+ statusTone: 'cancelled',
72
+ statusLabel: 'Cancelled',
73
+ titleLabel: 'Tool Result',
74
+ },
75
+ });
76
+ });
77
+ });
@@ -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);