@nextclaw/ui 0.9.1 → 0.9.3

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 (81) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-ZBPiF0y2.js +1 -0
  3. package/dist/assets/ChatPage-BOgoolWK.js +38 -0
  4. package/dist/assets/{DocBrowser-LpzGe8An.js → DocBrowser-BUYNHg0Y.js} +1 -1
  5. package/dist/assets/LogoBadge-DXPq99LJ.js +1 -0
  6. package/dist/assets/MarketplacePage-Dx7nexYN.js +49 -0
  7. package/dist/assets/McpMarketplacePage-064wdotP.js +40 -0
  8. package/dist/assets/{ModelConfig-DuImUHIX.js → ModelConfig-BDIfLesG.js} +1 -1
  9. package/dist/assets/ProvidersList-DrlIr46m.js +1 -0
  10. package/dist/assets/RemoteAccessPage-ZkUBA-Av.js +1 -0
  11. package/dist/assets/{RuntimeConfig-C6iqpJR_.js → RuntimeConfig-BPxXEGzM.js} +1 -1
  12. package/dist/assets/{SearchConfig-Dvp1TAXu.js → SearchConfig-BIqnlpne.js} +1 -1
  13. package/dist/assets/{SecretsConfig-D5Ymlvt9.js → SecretsConfig-jKZEVF2q.js} +2 -2
  14. package/dist/assets/{SessionsConfig-CIA_jA1P.js → SessionsConfig-C_FXgVe1.js} +2 -2
  15. package/dist/assets/{chat-message-B60Fh9kI.js → chat-message-DmzpZJc_.js} +1 -1
  16. package/dist/assets/index-Byfw276e.js +8 -0
  17. package/dist/assets/{index-CPDASUXh.js → index-Ct7FQpxN.js} +1 -1
  18. package/dist/assets/index-bhNuQis7.css +1 -0
  19. package/dist/assets/{label-D4fGx6Wb.js → label-B1MloEtn.js} +1 -1
  20. package/dist/assets/marketplace-localization-Dk31LJJJ.js +1 -0
  21. package/dist/assets/{page-layout-twy8gmBE.js → page-layout-BGg1EhM5.js} +1 -1
  22. package/dist/assets/{popover-DYbYpt1j.js → popover-jJMv74Fp.js} +1 -1
  23. package/dist/assets/{security-config-BcIZ4rpb.js → security-config-Boh9NIMz.js} +1 -1
  24. package/dist/assets/skeleton-CmATs_b3.js +1 -0
  25. package/dist/assets/status-dot-DNyCdxPZ.js +1 -0
  26. package/dist/assets/{switch-DqA6r5XR.js → switch-DE_MYk7x.js} +1 -1
  27. package/dist/assets/{tabs-custom-C6enKKs1.js → tabs-custom-B-zErYPr.js} +1 -1
  28. package/dist/assets/{useConfirmDialog-CHBf5Of7.js → useConfirmDialog-BqQ6QfhB.js} +2 -2
  29. package/dist/assets/{vendor-DKBNiC31.js → vendor-CwsIoNvJ.js} +128 -93
  30. package/dist/index.html +3 -3
  31. package/package.json +4 -4
  32. package/src/App.tsx +4 -0
  33. package/src/api/auth.types.ts +24 -0
  34. package/src/api/chat-session-type.types.ts +21 -0
  35. package/src/api/marketplace.ts +8 -2
  36. package/src/api/mcp-marketplace.ts +138 -0
  37. package/src/api/remote.ts +57 -0
  38. package/src/api/remote.types.ts +80 -0
  39. package/src/api/types.ts +91 -37
  40. package/src/components/chat/ChatSidebar.test.tsx +31 -2
  41. package/src/components/chat/ChatSidebar.tsx +26 -2
  42. package/src/components/chat/chat-page-data.ts +37 -53
  43. package/src/components/chat/chat-page-runtime.test.ts +122 -2
  44. package/src/components/chat/chat-page-runtime.ts +1 -118
  45. package/src/components/chat/chat-session-preference-governance.ts +303 -0
  46. package/src/components/chat/legacy/LegacyChatPage.tsx +4 -34
  47. package/src/components/chat/ncp/NcpChatPage.tsx +4 -34
  48. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +36 -0
  49. package/src/components/chat/ncp/ncp-chat-page-data.ts +63 -36
  50. package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
  51. package/src/components/chat/ncp/ncp-session-adapter.ts +2 -0
  52. package/src/components/chat/stores/chat-input.store.ts +14 -1
  53. package/src/components/chat/useChatSessionTypeState.test.tsx +29 -0
  54. package/src/components/chat/useChatSessionTypeState.ts +55 -12
  55. package/src/components/layout/Sidebar.tsx +11 -1
  56. package/src/components/marketplace/MarketplacePage.test.tsx +152 -0
  57. package/src/components/marketplace/MarketplacePage.tsx +52 -199
  58. package/src/components/marketplace/marketplace-installed-cache.test.ts +110 -0
  59. package/src/components/marketplace/marketplace-installed-cache.ts +149 -0
  60. package/src/components/marketplace/marketplace-localization.ts +77 -0
  61. package/src/components/marketplace/marketplace-page-parts.tsx +102 -0
  62. package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +208 -0
  63. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +578 -0
  64. package/src/components/remote/RemoteAccessPage.tsx +320 -0
  65. package/src/components/ui/input.tsx +1 -1
  66. package/src/components/ui/label.tsx +1 -1
  67. package/src/hooks/useMarketplace.ts +36 -7
  68. package/src/hooks/useMcpMarketplace.ts +99 -0
  69. package/src/hooks/useRemoteAccess.ts +92 -0
  70. package/src/hooks/useWebSocket.ts +25 -16
  71. package/src/lib/i18n.marketplace.ts +91 -0
  72. package/src/lib/i18n.remote.ts +115 -0
  73. package/src/lib/i18n.ts +10 -68
  74. package/dist/assets/ChannelsList-DhvjpZcs.js +0 -1
  75. package/dist/assets/ChatPage-B8VBaMQm.js +0 -38
  76. package/dist/assets/LogoBadge-Be4lktJN.js +0 -1
  77. package/dist/assets/MarketplacePage-Cx9AI3_h.js +0 -49
  78. package/dist/assets/ProvidersList-Ccleg25k.js +0 -1
  79. package/dist/assets/index-BiPDnzv0.js +0 -8
  80. package/dist/assets/index-C8GsgIUn.css +0 -1
  81. package/dist/assets/skeleton-DypBy7jp.js +0 -1
@@ -1,17 +1,15 @@
1
1
  import { useMemo } from 'react';
2
2
  import type { Dispatch, SetStateAction } from 'react';
3
- import type { SessionEntryView } from '@/api/types';
3
+ import type { SessionEntryView, ThinkingLevel } from '@/api/types';
4
4
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
- import {
6
- adaptNcpSessionSummaries,
7
- readNcpSessionPreferredThinking
8
- } from '@/components/chat/ncp/ncp-session-adapter';
5
+ import { adaptNcpSessionSummaries } from '@/components/chat/ncp/ncp-session-adapter';
9
6
  import { useChatSessionTypeState } from '@/components/chat/useChatSessionTypeState';
10
7
  import {
11
- resolveSelectedModelValue,
8
+ resolveRecentSessionPreferredThinking,
12
9
  resolveRecentSessionPreferredModel,
13
- useSyncSelectedModel
14
- } from '@/components/chat/chat-page-runtime';
10
+ useSyncSelectedModel,
11
+ useSyncSelectedThinking
12
+ } from '@/components/chat/chat-session-preference-governance';
15
13
  import {
16
14
  useConfig,
17
15
  useConfigMeta,
@@ -24,9 +22,11 @@ import { buildProviderModelCatalog, composeProviderModel, resolveModelThinkingCa
24
22
  type UseNcpChatPageDataParams = {
25
23
  query: string;
26
24
  selectedSessionKey: string | null;
25
+ currentSelectedModel: string;
27
26
  pendingSessionType: string;
28
27
  setPendingSessionType: Dispatch<SetStateAction<string>>;
29
28
  setSelectedModel: Dispatch<SetStateAction<string>>;
29
+ setSelectedThinkingLevel: Dispatch<SetStateAction<ThinkingLevel | null>>;
30
30
  };
31
31
 
32
32
  function filterSessionsByQuery(sessions: SessionEntryView[], query: string): SessionEntryView[] {
@@ -37,6 +37,18 @@ function filterSessionsByQuery(sessions: SessionEntryView[], query: string): Ses
37
37
  return sessions.filter((session) => session.key.toLowerCase().includes(normalizedQuery));
38
38
  }
39
39
 
40
+ export function filterModelOptionsBySessionType(params: {
41
+ modelOptions: ChatModelOption[];
42
+ supportedModels?: string[];
43
+ }): ChatModelOption[] {
44
+ if (!params.supportedModels || params.supportedModels.length === 0) {
45
+ return params.modelOptions;
46
+ }
47
+ const supportedModelSet = new Set(params.supportedModels);
48
+ const filtered = params.modelOptions.filter((option) => supportedModelSet.has(option.value));
49
+ return filtered.length > 0 ? filtered : params.modelOptions;
50
+ }
51
+
40
52
  export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
41
53
  const configQuery = useConfig();
42
54
  const configMetaQuery = useConfigMeta();
@@ -95,19 +107,10 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
95
107
  () => allSessions.find((session) => session.key === params.selectedSessionKey) ?? null,
96
108
  [allSessions, params.selectedSessionKey]
97
109
  );
98
- const selectedSessionSummary = useMemo(
99
- () => sessionSummaries.find((session) => session.sessionId === params.selectedSessionKey) ?? null,
100
- [params.selectedSessionKey, sessionSummaries]
101
- );
102
110
  const skillRecords = useMemo(
103
111
  () => installedSkillsQuery.data?.records ?? [],
104
112
  [installedSkillsQuery.data?.records]
105
113
  );
106
- const selectedSessionThinkingLevel = useMemo(
107
- () => (selectedSessionSummary ? readNcpSessionPreferredThinking(selectedSessionSummary) : null),
108
- [selectedSessionSummary]
109
- );
110
-
111
114
  const sessionTypeState = useChatSessionTypeState({
112
115
  selectedSession,
113
116
  selectedSessionKey: params.selectedSessionKey,
@@ -115,6 +118,14 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
115
118
  setPendingSessionType: params.setPendingSessionType,
116
119
  sessionTypesData: sessionTypesQuery.data
117
120
  });
121
+ const filteredModelOptions = useMemo(
122
+ () =>
123
+ filterModelOptionsBySessionType({
124
+ modelOptions,
125
+ supportedModels: sessionTypeState.selectedSessionTypeOption?.supportedModels
126
+ }),
127
+ [modelOptions, sessionTypeState.selectedSessionTypeOption?.supportedModels]
128
+ );
118
129
  const recentSessionPreferredModel = useMemo(
119
130
  () =>
120
131
  resolveRecentSessionPreferredModel({
@@ -124,28 +135,46 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
124
135
  }),
125
136
  [allSessions, params.selectedSessionKey, sessionTypeState.selectedSessionType]
126
137
  );
138
+ const currentModelOption = useMemo(
139
+ () => filteredModelOptions.find((option) => option.value === params.currentSelectedModel),
140
+ [filteredModelOptions, params.currentSelectedModel]
141
+ );
142
+ const supportedThinkingLevels = useMemo(
143
+ () => (currentModelOption?.thinkingCapability?.supported as ThinkingLevel[] | undefined) ?? [],
144
+ [currentModelOption?.thinkingCapability?.supported]
145
+ );
146
+ const defaultThinkingLevel = useMemo(
147
+ () => (currentModelOption?.thinkingCapability?.default as ThinkingLevel | null | undefined) ?? null,
148
+ [currentModelOption?.thinkingCapability?.default]
149
+ );
150
+ const recentSessionPreferredThinking = useMemo(
151
+ () =>
152
+ resolveRecentSessionPreferredThinking({
153
+ sessions: allSessions,
154
+ selectedSessionKey: params.selectedSessionKey,
155
+ sessionType: sessionTypeState.selectedSessionType
156
+ }),
157
+ [allSessions, params.selectedSessionKey, sessionTypeState.selectedSessionType]
158
+ );
127
159
 
128
160
  useSyncSelectedModel({
129
- modelOptions,
161
+ modelOptions: filteredModelOptions,
130
162
  selectedSessionKey: params.selectedSessionKey,
163
+ selectedSessionExists: Boolean(selectedSession),
131
164
  selectedSessionPreferredModel: selectedSession?.preferredModel,
132
- fallbackPreferredModel: recentSessionPreferredModel,
133
- defaultModel: configQuery.data?.agents.defaults.model,
165
+ fallbackPreferredModel: sessionTypeState.selectedSessionTypeOption?.recommendedModel ?? recentSessionPreferredModel,
166
+ defaultModel: sessionTypeState.selectedSessionTypeOption?.recommendedModel ?? configQuery.data?.agents.defaults.model,
134
167
  setSelectedModel: params.setSelectedModel
135
168
  });
136
-
137
- const hydratedSessionModel = useMemo(
138
- () =>
139
- resolveSelectedModelValue({
140
- currentSelectedModel: '',
141
- modelOptions,
142
- selectedSessionPreferredModel: selectedSession?.preferredModel,
143
- fallbackPreferredModel: recentSessionPreferredModel,
144
- defaultModel: configQuery.data?.agents.defaults.model,
145
- preferSessionPreferredModel: true
146
- }),
147
- [configQuery.data?.agents.defaults.model, modelOptions, recentSessionPreferredModel, selectedSession?.preferredModel]
148
- );
169
+ useSyncSelectedThinking({
170
+ supportedThinkingLevels,
171
+ selectedSessionKey: params.selectedSessionKey,
172
+ selectedSessionExists: Boolean(selectedSession),
173
+ selectedSessionPreferredThinking: selectedSession?.preferredThinking ?? null,
174
+ fallbackPreferredThinking: recentSessionPreferredThinking ?? null,
175
+ defaultThinkingLevel,
176
+ setSelectedThinkingLevel: params.setSelectedThinkingLevel
177
+ });
149
178
 
150
179
  return {
151
180
  configQuery,
@@ -154,13 +183,11 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
154
183
  sessionTypesQuery,
155
184
  installedSkillsQuery,
156
185
  isProviderStateResolved,
157
- modelOptions,
186
+ modelOptions: filteredModelOptions,
158
187
  sessionSummaries,
159
188
  sessions,
160
189
  skillRecords,
161
190
  selectedSession,
162
- hydratedSessionModel,
163
- selectedSessionThinkingLevel,
164
191
  ...sessionTypeState
165
192
  };
166
193
  }
@@ -22,6 +22,7 @@ describe('adaptNcpSessionSummary', () => {
22
22
  metadata: {
23
23
  label: 'NCP Planning Thread',
24
24
  model: 'openai/gpt-5',
25
+ preferred_thinking: 'medium',
25
26
  session_type: 'native'
26
27
  }
27
28
  })
@@ -31,6 +32,7 @@ describe('adaptNcpSessionSummary', () => {
31
32
  key: 'ncp-session-1',
32
33
  label: 'NCP Planning Thread',
33
34
  preferredModel: 'openai/gpt-5',
35
+ preferredThinking: 'medium',
34
36
  sessionType: 'native',
35
37
  sessionTypeMutable: false,
36
38
  messageCount: 3
@@ -175,12 +175,14 @@ export function adaptNcpMessagesToUiMessages(messages: readonly NcpMessageView[]
175
175
  export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionEntryView {
176
176
  const label = readNcpSessionLabel(summary);
177
177
  const preferredModel = readNcpSessionPreferredModel(summary);
178
+ const preferredThinking = readNcpSessionPreferredThinking(summary);
178
179
  return {
179
180
  key: summary.sessionId,
180
181
  createdAt: summary.updatedAt,
181
182
  updatedAt: summary.updatedAt,
182
183
  ...(label ? { label } : {}),
183
184
  ...(preferredModel ? { preferredModel } : {}),
185
+ ...(preferredThinking ? { preferredThinking } : {}),
184
186
  sessionType: readNcpSessionType(summary),
185
187
  sessionTypeMutable: false,
186
188
  messageCount: summary.messageCount
@@ -18,7 +18,20 @@ export type ChatInputSnapshot = {
18
18
  modelOptions: ChatModelOption[];
19
19
  selectedModel: string;
20
20
  selectedThinkingLevel: ThinkingLevel | null;
21
- sessionTypeOptions: Array<{ value: string; label: string }>;
21
+ sessionTypeOptions: Array<{
22
+ value: string;
23
+ label: string;
24
+ ready?: boolean;
25
+ reason?: string | null;
26
+ reasonMessage?: string | null;
27
+ supportedModels?: string[];
28
+ recommendedModel?: string | null;
29
+ cta?: {
30
+ kind: string;
31
+ label?: string;
32
+ href?: string;
33
+ } | null;
34
+ }>;
22
35
  selectedSessionType?: string;
23
36
  stopSupported: boolean;
24
37
  stopReason?: string;
@@ -55,4 +55,33 @@ describe('useChatSessionTypeState', () => {
55
55
 
56
56
  expect(setPendingSessionType).toHaveBeenCalledWith('codex-sdk');
57
57
  });
58
+
59
+ it('marks the selected draft session type as unavailable when runtime setup is incomplete', () => {
60
+ const setPendingSessionType = vi.fn();
61
+
62
+ const { result } = renderHook(() =>
63
+ useChatSessionTypeState({
64
+ selectedSession: null,
65
+ selectedSessionKey: null,
66
+ pendingSessionType: 'claude',
67
+ setPendingSessionType,
68
+ sessionTypesData: {
69
+ defaultType: 'native',
70
+ options: [
71
+ { value: 'native', label: 'Native', ready: true },
72
+ {
73
+ value: 'claude',
74
+ label: 'Claude',
75
+ ready: false,
76
+ reasonMessage: 'Configure a provider API key first.'
77
+ }
78
+ ]
79
+ }
80
+ })
81
+ );
82
+
83
+ expect(result.current.selectedSessionTypeOption?.ready).toBe(false);
84
+ expect(result.current.sessionTypeUnavailable).toBe(true);
85
+ expect(result.current.sessionTypeUnavailableMessage).toBe('Configure a provider API key first.');
86
+ });
58
87
  });
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useMemo, useRef } from 'react';
2
2
  import type { Dispatch, SetStateAction } from 'react';
3
- import type { SessionEntryView } from '@/api/types';
3
+ import type { ChatSessionTypeOptionView, SessionEntryView } from '@/api/types';
4
4
  import { t } from '@/lib/i18n';
5
5
 
6
6
  export const DEFAULT_SESSION_TYPE = 'native';
@@ -8,6 +8,16 @@ export const DEFAULT_SESSION_TYPE = 'native';
8
8
  export type ChatSessionTypeOption = {
9
9
  value: string;
10
10
  label: string;
11
+ ready: boolean;
12
+ reason?: string | null;
13
+ reasonMessage?: string | null;
14
+ supportedModels?: string[];
15
+ recommendedModel?: string | null;
16
+ cta?: {
17
+ kind: string;
18
+ label?: string;
19
+ href?: string;
20
+ } | null;
11
21
  };
12
22
 
13
23
  type UseChatSessionTypeStateParams = {
@@ -17,7 +27,7 @@ type UseChatSessionTypeStateParams = {
17
27
  setPendingSessionType: Dispatch<SetStateAction<string>>;
18
28
  sessionTypesData?: {
19
29
  defaultType?: string;
20
- options?: Array<{ value: string; label: string }>;
30
+ options?: ChatSessionTypeOptionView[];
21
31
  } | null;
22
32
  };
23
33
 
@@ -46,20 +56,32 @@ export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: str
46
56
  }
47
57
 
48
58
  function buildSessionTypeOptions(
49
- options: Array<{ value: string; label: string }>
59
+ options: ChatSessionTypeOptionView[]
50
60
  ): ChatSessionTypeOption[] {
51
61
  const deduped = new Map<string, ChatSessionTypeOption>();
52
62
  for (const option of options) {
53
63
  const value = normalizeSessionType(option.value);
54
64
  deduped.set(value, {
55
65
  value,
56
- label: option.label?.trim() || resolveSessionTypeLabel(value)
66
+ label: option.label?.trim() || resolveSessionTypeLabel(value),
67
+ ready: option.ready ?? true,
68
+ reason: option.reason ?? null,
69
+ reasonMessage: option.reasonMessage ?? null,
70
+ supportedModels: option.supportedModels,
71
+ recommendedModel: option.recommendedModel ?? null,
72
+ cta: option.cta ?? null
57
73
  });
58
74
  }
59
75
  if (!deduped.has(DEFAULT_SESSION_TYPE)) {
60
76
  deduped.set(DEFAULT_SESSION_TYPE, {
61
77
  value: DEFAULT_SESSION_TYPE,
62
- label: resolveSessionTypeLabel(DEFAULT_SESSION_TYPE)
78
+ label: resolveSessionTypeLabel(DEFAULT_SESSION_TYPE),
79
+ ready: true,
80
+ reason: null,
81
+ reasonMessage: null,
82
+ supportedModels: undefined,
83
+ recommendedModel: null,
84
+ cta: null
63
85
  });
64
86
  }
65
87
  return Array.from(deduped.values()).sort((left, right) => {
@@ -75,6 +97,7 @@ function buildSessionTypeOptions(
75
97
 
76
98
  export function useChatSessionTypeState(params: UseChatSessionTypeStateParams): {
77
99
  sessionTypeOptions: ChatSessionTypeOption[];
100
+ selectedSessionTypeOption: ChatSessionTypeOption | null;
78
101
  defaultSessionType: string;
79
102
  selectedSessionType: string;
80
103
  canEditSessionType: boolean;
@@ -99,7 +122,13 @@ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams):
99
122
  if (!options.some((option) => option.value === currentSessionType)) {
100
123
  options.push({
101
124
  value: currentSessionType,
102
- label: resolveSessionTypeLabel(currentSessionType)
125
+ label: resolveSessionTypeLabel(currentSessionType),
126
+ ready: true,
127
+ reason: null,
128
+ reasonMessage: null,
129
+ supportedModels: undefined,
130
+ recommendedModel: null,
131
+ cta: null
103
132
  });
104
133
  }
105
134
  return options.sort((left, right) => {
@@ -121,6 +150,10 @@ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams):
121
150
  () => normalizeSessionType(selectedSession?.sessionType ?? pendingSessionType ?? defaultSessionType),
122
151
  [defaultSessionType, pendingSessionType, selectedSession?.sessionType]
123
152
  );
153
+ const selectedSessionTypeOption = useMemo(
154
+ () => sessionTypeOptions.find((option) => option.value === selectedSessionType) ?? null,
155
+ [selectedSessionType, sessionTypeOptions]
156
+ );
124
157
 
125
158
  useEffect(() => {
126
159
  if (selectedSessionKey) {
@@ -147,15 +180,25 @@ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams):
147
180
  () => new Set(runtimeSessionTypeOptions.map((option) => option.value)),
148
181
  [runtimeSessionTypeOptions]
149
182
  );
150
- const sessionTypeUnavailable = Boolean(
151
- selectedSession && !availableSessionTypeSet.has(normalizeSessionType(selectedSession.sessionType))
152
- );
153
- const sessionTypeUnavailableMessage = sessionTypeUnavailable
154
- ? `${resolveSessionTypeLabel(selectedSessionType)} ${t('chatSessionTypeUnavailableSuffix')}`
155
- : null;
183
+ const sessionTypeUnavailable = useMemo(() => {
184
+ if (selectedSession && !availableSessionTypeSet.has(normalizeSessionType(selectedSession.sessionType))) {
185
+ return true;
186
+ }
187
+ return selectedSessionTypeOption?.ready === false;
188
+ }, [availableSessionTypeSet, selectedSession, selectedSessionTypeOption?.ready]);
189
+ const sessionTypeUnavailableMessage = useMemo(() => {
190
+ if (selectedSession && !availableSessionTypeSet.has(normalizeSessionType(selectedSession.sessionType))) {
191
+ return `${resolveSessionTypeLabel(selectedSessionType)} ${t('chatSessionTypeUnavailableSuffix')}`;
192
+ }
193
+ if (selectedSessionTypeOption?.ready === false) {
194
+ return selectedSessionTypeOption.reasonMessage?.trim() || `${selectedSessionTypeOption.label} setup required`;
195
+ }
196
+ return null;
197
+ }, [availableSessionTypeSet, selectedSession, selectedSessionType, selectedSessionTypeOption]);
156
198
 
157
199
  return {
158
200
  sessionTypeOptions,
201
+ selectedSessionTypeOption,
159
202
  defaultSessionType,
160
203
  selectedSessionType,
161
204
  canEditSessionType,
@@ -1,7 +1,7 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
3
3
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
4
- import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield } from 'lucide-react';
4
+ import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield, Wrench, Wifi } from 'lucide-react';
5
5
  import { NavLink } from 'react-router-dom';
6
6
  import { useDocBrowser } from '@/components/doc-browser';
7
7
  import { BrandHeader } from '@/components/common/BrandHeader';
@@ -82,6 +82,11 @@ export function Sidebar({ mode }: SidebarProps) {
82
82
  label: t('runtime'),
83
83
  icon: GitBranch,
84
84
  },
85
+ {
86
+ target: '/remote',
87
+ label: t('remote'),
88
+ icon: Wifi,
89
+ },
85
90
  {
86
91
  target: '/security',
87
92
  label: t('security'),
@@ -101,6 +106,11 @@ export function Sidebar({ mode }: SidebarProps) {
101
106
  target: '/marketplace/plugins',
102
107
  label: t('marketplaceFilterPlugins'),
103
108
  icon: Plug,
109
+ },
110
+ {
111
+ target: '/marketplace/mcp',
112
+ label: t('marketplaceFilterMcp'),
113
+ icon: Wrench,
104
114
  }
105
115
  ];
106
116
  const navItems = mode === 'main' ? mainNavItems : settingsNavItems;
@@ -1,6 +1,8 @@
1
1
  import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
2
3
  import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
3
4
  import type {
5
+ MarketplaceInstalledRecord,
4
6
  MarketplaceInstalledView,
5
7
  MarketplaceItemSummary,
6
8
  MarketplaceListView
@@ -35,6 +37,7 @@ const mocks = vi.hoisted(() => ({
35
37
  },
36
38
  manageMutation: {
37
39
  mutate: vi.fn(),
40
+ mutateAsync: vi.fn(),
38
41
  isPending: false,
39
42
  variables: undefined
40
43
  }
@@ -95,6 +98,37 @@ function createMarketplaceItem(overrides: Partial<MarketplaceItemSummary> = {}):
95
98
  };
96
99
  }
97
100
 
101
+ function createPluginMarketplaceItem(overrides: Partial<MarketplaceItemSummary> = {}): MarketplaceItemSummary {
102
+ return createMarketplaceItem({
103
+ id: 'plugin-codex-runtime',
104
+ slug: 'codex-runtime',
105
+ type: 'plugin',
106
+ name: 'Codex SDK NCP Runtime',
107
+ summary: 'Optional Codex runtime for NextClaw',
108
+ summaryI18n: { en: 'Optional Codex runtime for NextClaw' },
109
+ install: {
110
+ kind: 'npm',
111
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
112
+ command: 'npm install @nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk'
113
+ },
114
+ ...overrides
115
+ });
116
+ }
117
+
118
+ function createInstalledRecord(overrides: Partial<MarketplaceInstalledRecord> = {}): MarketplaceInstalledRecord {
119
+ return {
120
+ type: 'plugin',
121
+ id: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
122
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
123
+ label: 'Codex SDK NCP Runtime',
124
+ enabled: true,
125
+ origin: 'marketplace',
126
+ source: 'marketplace',
127
+ installedAt: '2026-03-19T00:00:00.000Z',
128
+ ...overrides
129
+ };
130
+ }
131
+
98
132
  function createItemsQuery(overrides: Partial<Record<string, unknown>> = {}) {
99
133
  return {
100
134
  data: undefined as MarketplaceListView | undefined,
@@ -129,6 +163,7 @@ describe('MarketplacePage', () => {
129
163
  mocks.confirm.mockReset();
130
164
  mocks.installMutation.mutateAsync.mockReset();
131
165
  mocks.manageMutation.mutate.mockReset();
166
+ mocks.manageMutation.mutateAsync.mockReset();
132
167
  mocks.installMutation.isPending = false;
133
168
  mocks.installMutation.variables = undefined;
134
169
  mocks.manageMutation.isPending = false;
@@ -167,4 +202,121 @@ describe('MarketplacePage', () => {
167
202
  expect(screen.queryByTestId('marketplace-list-skeleton')).toBeNull();
168
203
  expect(screen.getByText('Web Search')).toBeTruthy();
169
204
  });
205
+
206
+ it('does not render the redundant plugin type label in plugin cards', () => {
207
+ mocks.itemsQuery = createItemsQuery({
208
+ data: {
209
+ total: 1,
210
+ page: 1,
211
+ pageSize: 12,
212
+ totalPages: 1,
213
+ sort: 'relevance',
214
+ items: [
215
+ createMarketplaceItem({
216
+ id: 'plugin-codex-runtime',
217
+ slug: 'codex-runtime',
218
+ type: 'plugin',
219
+ name: 'Codex SDK NCP Runtime',
220
+ summary: 'Optional Codex runtime for NextClaw',
221
+ summaryI18n: { en: 'Optional Codex runtime for NextClaw' },
222
+ install: {
223
+ kind: 'npm',
224
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
225
+ command: 'npm install @nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk'
226
+ }
227
+ })
228
+ ]
229
+ } satisfies MarketplaceListView
230
+ });
231
+
232
+ const { container } = render(<MarketplacePage forcedType="plugins" />);
233
+ const card = container.querySelector('article');
234
+
235
+ expect(card?.textContent).toContain('@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk');
236
+ expect(card?.textContent).not.toContain('Plugin');
237
+ });
238
+
239
+ it('does not dim the loaded list during background refresh', () => {
240
+ mocks.itemsQuery = createItemsQuery({
241
+ data: {
242
+ total: 1,
243
+ page: 1,
244
+ pageSize: 12,
245
+ totalPages: 1,
246
+ sort: 'relevance',
247
+ items: [createPluginMarketplaceItem()]
248
+ } satisfies MarketplaceListView,
249
+ isFetching: true
250
+ });
251
+
252
+ const { container } = render(<MarketplacePage forcedType="plugins" />);
253
+
254
+ expect(screen.getByText('Codex SDK NCP Runtime')).toBeTruthy();
255
+ expect(container.querySelector('.opacity-70')).toBeNull();
256
+ });
257
+
258
+ it('only disables the targeted plugin action while a manage request is pending', async () => {
259
+ const user = userEvent.setup();
260
+ let resolveMutation: (() => void) | undefined;
261
+ mocks.itemsQuery = createItemsQuery({
262
+ data: {
263
+ total: 2,
264
+ page: 1,
265
+ pageSize: 12,
266
+ totalPages: 1,
267
+ sort: 'relevance',
268
+ items: [
269
+ createPluginMarketplaceItem(),
270
+ createPluginMarketplaceItem({
271
+ id: 'plugin-claude-runtime',
272
+ slug: 'claude-runtime',
273
+ name: 'Claude Agent Runtime',
274
+ install: {
275
+ kind: 'npm',
276
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk',
277
+ command: 'npm install @nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk'
278
+ }
279
+ })
280
+ ]
281
+ } satisfies MarketplaceListView
282
+ });
283
+ mocks.installedQuery = createInstalledQuery({
284
+ data: {
285
+ type: 'plugin',
286
+ total: 2,
287
+ specs: [
288
+ '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
289
+ '@nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk'
290
+ ],
291
+ records: [
292
+ createInstalledRecord(),
293
+ createInstalledRecord({
294
+ id: '@nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk',
295
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk',
296
+ label: 'Claude Agent Runtime'
297
+ })
298
+ ]
299
+ } satisfies MarketplaceInstalledView
300
+ });
301
+ mocks.manageMutation.mutateAsync.mockImplementation(
302
+ () => new Promise<void>((resolve) => {
303
+ resolveMutation = resolve;
304
+ })
305
+ );
306
+
307
+ render(<MarketplacePage forcedType="plugins" />);
308
+
309
+ const disableButtons = screen.getAllByRole('button', { name: 'Disable' });
310
+ const firstDisableButton = disableButtons[0];
311
+ const secondDisableButton = disableButtons[1];
312
+
313
+ await user.click(firstDisableButton);
314
+
315
+ expect(mocks.manageMutation.mutateAsync).toHaveBeenCalledTimes(1);
316
+ expect(firstDisableButton.hasAttribute('disabled')).toBe(true);
317
+ expect(firstDisableButton.textContent).toContain('Disabling');
318
+ expect(secondDisableButton.hasAttribute('disabled')).toBe(false);
319
+
320
+ resolveMutation?.();
321
+ });
170
322
  });