@nextclaw/ui 0.12.7 → 0.12.9

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 (171) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/assets/ChannelsList-Ita2Zm1_.js +8 -0
  3. package/dist/assets/{DocBrowser-Cse_F8Nn.js → DocBrowser-6ReNjvzF.js} +1 -1
  4. package/dist/assets/DocBrowser-BNwbPHf4.js +1 -0
  5. package/dist/assets/{DocBrowserContext-Bai1WU2H.js → DocBrowserContext-B6SpA7Qs.js} +1 -1
  6. package/dist/assets/{LogoBadge-BdxMPc9v.js → LogoBadge-ByNLYg65.js} +1 -1
  7. package/dist/assets/MarketplacePage-CjX2MWww.js +1 -0
  8. package/dist/assets/{MarketplacePage-BbpAkllU.js → MarketplacePage-D0sDlYX4.js} +1 -1
  9. package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +40 -0
  10. package/dist/assets/{ModelConfig-3GLqQ5GY.js → ModelConfig-BzZenCH-.js} +1 -1
  11. package/dist/assets/{ProviderScopedModelInput-BYNouw-i.js → ProviderScopedModelInput-Da7khnBA.js} +1 -1
  12. package/dist/assets/{ProvidersList-BR1gJ4Dm.js → ProvidersList-BbVzRxjY.js} +1 -1
  13. package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +1 -0
  14. package/dist/assets/RuntimeConfig-F_XKGgLm.js +1 -0
  15. package/dist/assets/{SearchConfig-DTeJvp8m.js → SearchConfig-BGkzXQP-.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CCYO6NcV.js → SecretsConfig-D281Rotl.js} +2 -2
  17. package/dist/assets/{SessionsConfig-Du39vDgt.js → SessionsConfig-ChHQ7M5c.js} +2 -2
  18. package/dist/assets/{app-query-client-Dr5d-K8d.js → app-query-client-VnFElj4E.js} +1 -1
  19. package/dist/assets/{book-open-Da4OEPqB.js → book-open-BdcxxoQu.js} +1 -1
  20. package/dist/assets/chat-page-Doe0yTtB.js +58 -0
  21. package/dist/assets/chat-session-display-cw78aiI_.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-CoFVxHXV.js → chunk-JZWAC4HX-DK5HPmIK.js} +1 -1
  23. package/dist/assets/{client-CSk58DcF.js → client-_i4MU2bB.js} +1 -1
  24. package/dist/assets/{config-D8KzikVB.js → config-DtIQwrHF.js} +1 -1
  25. package/dist/assets/{createLucideIcon-83gaZMtv.js → createLucideIcon-BSeTgkZW.js} +1 -1
  26. package/dist/assets/desktop-update-config-Dpcf4BKG.js +1 -0
  27. package/dist/assets/{dist-toEYs-MZ.js → dist-6TrrnPCR.js} +1 -1
  28. package/dist/assets/{dist-aTmhMDVh.js → dist-ccBFUi-o.js} +1 -1
  29. package/dist/assets/download-BhDxnyvU.js +1 -0
  30. package/dist/assets/{external-link-QQ0TC6X4.js → external-link-BgErLCNT.js} +1 -1
  31. package/dist/assets/{hash-DaFBEkmi.js → hash-Bl7dr_UG.js} +1 -1
  32. package/dist/assets/i18n-eDHeDY0n.js +1 -0
  33. package/dist/assets/index-CF9xve0E.js +6 -0
  34. package/dist/assets/index-FgA52VBt.css +1 -0
  35. package/dist/assets/{infiniteQueryBehavior-BmHX_ayZ.js → infiniteQueryBehavior-ZDS92Qpp.js} +1 -1
  36. package/dist/assets/loader-circle-ACM1s51e.js +1 -0
  37. package/dist/assets/{logos-Dzlz30M3.js → logos-x89HbrZ4.js} +1 -1
  38. package/dist/assets/{page-layout-D2eRufRQ.js → page-layout-vZnghcFy.js} +1 -1
  39. package/dist/assets/play-CFUwCA2E.js +1 -0
  40. package/dist/assets/plus-rYsv72JG.js +1 -0
  41. package/dist/assets/{popover-BSXxm5bj.js → popover-Bg1VoTZ6.js} +1 -1
  42. package/dist/assets/{refresh-ccw-B3zMtN-_.js → refresh-ccw-DT98i__E.js} +1 -1
  43. package/dist/assets/{refresh-cw-DlZkIHnJ.js → refresh-cw-C47QSEwg.js} +1 -1
  44. package/dist/assets/rotate-cw-JtFzpNn6.js +1 -0
  45. package/dist/assets/{save-Us9fg4Sj.js → save-3S6-H3Xw.js} +1 -1
  46. package/dist/assets/search-3kFR_zh9.js +1 -0
  47. package/dist/assets/{security-config-BGWYwxNr.js → security-config-BWaiARNk.js} +1 -1
  48. package/dist/assets/{select-DLYqySQK.js → select-DJ2MUjBB.js} +1 -1
  49. package/dist/assets/skeleton-ByQepn0M.js +1 -0
  50. package/dist/assets/{status-dot-DGayudyB.js → status-dot-vbanNPFU.js} +1 -1
  51. package/dist/assets/{switch-Dz2ScsKx.js → switch-BsLtHOH-.js} +1 -1
  52. package/dist/assets/{tabs-custom-CdKyjiGk.js → tabs-custom-D3HYMt6k.js} +1 -1
  53. package/dist/assets/{trash-2-Db-mZOZs.js → trash-2-G48scll7.js} +1 -1
  54. package/dist/assets/{use-infinite-scroll-loader-DBJX5hj0.js → use-infinite-scroll-loader-DkNhD-42.js} +1 -1
  55. package/dist/assets/{useConfirmDialog-DL0a-oGC.js → useConfirmDialog-BkvTN-vd.js} +1 -1
  56. package/dist/assets/{useMutation-BdZm-9PL.js → useMutation-CBWjE2uj.js} +1 -1
  57. package/dist/assets/x-ByDbItbq.js +1 -0
  58. package/dist/index.html +95 -21
  59. package/dist/manifest.webmanifest +30 -0
  60. package/dist/offline.html +102 -0
  61. package/dist/pwa-192.png +0 -0
  62. package/dist/pwa-512.png +0 -0
  63. package/dist/sw.js +80 -0
  64. package/index.html +73 -1
  65. package/package.json +6 -6
  66. package/public/manifest.webmanifest +30 -0
  67. package/public/offline.html +102 -0
  68. package/public/pwa-192.png +0 -0
  69. package/public/pwa-512.png +0 -0
  70. package/public/sw.js +80 -0
  71. package/src/api/runtime-control.ts +34 -0
  72. package/src/api/runtime-control.types.ts +58 -0
  73. package/src/api/server-path.ts +27 -4
  74. package/src/api/types.ts +30 -10
  75. package/src/{App.test.tsx → app.test.tsx} +1 -1
  76. package/src/{App.tsx → app.tsx} +10 -1
  77. package/src/components/chat/ChatSidebar.test.tsx +79 -8
  78. package/src/components/chat/ChatSidebar.tsx +43 -26
  79. package/src/components/chat/adapters/chat-message.summary-truncation.test.ts +66 -0
  80. package/src/components/chat/adapters/file-operation/card.ts +9 -0
  81. package/src/components/chat/adapters/file-operation/diff.ts +14 -0
  82. package/src/components/chat/{ChatConversationPanel.test.tsx → chat-conversation-panel.test.tsx} +118 -155
  83. package/src/components/chat/chat-conversation-panel.tsx +412 -0
  84. package/src/components/chat/chat-page-runtime.test.ts +1 -1
  85. package/src/components/chat/chat-page-shell.tsx +1 -1
  86. package/src/components/chat/{ChatPage.tsx → chat-page.tsx} +1 -1
  87. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +91 -0
  88. package/src/components/chat/chat-session-workspace-file-preview.tsx +307 -0
  89. package/src/components/chat/chat-session-workspace-panel-nav.tsx +197 -0
  90. package/src/components/chat/chat-session-workspace-panel.tsx +318 -0
  91. package/src/components/chat/chat-sidebar-session-item.tsx +32 -2
  92. package/src/components/chat/containers/chat-message-list.container.test.tsx +49 -0
  93. package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
  94. package/src/components/chat/managers/chat-session-list.manager.test.ts +94 -31
  95. package/src/components/chat/managers/chat-session-list.manager.ts +86 -14
  96. package/src/components/chat/managers/chat-ui.manager.ts +2 -0
  97. package/src/components/chat/ncp/README.md +1 -1
  98. package/src/components/chat/ncp/ncp-chat-input.manager.ts +7 -1
  99. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +1 -1
  100. package/src/components/chat/ncp/{NcpChatPage.tsx → ncp-chat-page.tsx} +7 -7
  101. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +179 -41
  102. package/src/components/chat/ncp/ncp-session-adapter.test.ts +40 -2
  103. package/src/components/chat/ncp/ncp-session-adapter.ts +29 -0
  104. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +54 -11
  105. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +4 -0
  106. package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +99 -0
  107. package/src/components/chat/ncp/tests/ncp-chat-thread.manager.test.ts +189 -0
  108. package/src/components/chat/presenter/chat-presenter-context.tsx +13 -2
  109. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +26 -0
  110. package/src/components/chat/session-header/chat-session-header-actions.tsx +19 -1
  111. package/src/components/chat/stores/chat-session-list.store.ts +25 -54
  112. package/src/components/chat/stores/chat-thread.store.ts +24 -0
  113. package/src/components/common/ProviderScopedModelInput.tsx +12 -2
  114. package/src/components/config/ModelConfig.test.tsx +108 -2
  115. package/src/components/config/RuntimeConfig.tsx +154 -7
  116. package/src/components/config/desktop-update-config.test.tsx +85 -0
  117. package/src/components/config/desktop-update-config.tsx +44 -3
  118. package/src/components/config/runtime-control-card.test.tsx +255 -0
  119. package/src/components/config/runtime-control-card.tsx +301 -0
  120. package/src/components/config/runtime-presence-card.test.tsx +154 -0
  121. package/src/components/config/runtime-presence-card.tsx +163 -0
  122. package/src/components/layout/AppLayout.tsx +1 -1
  123. package/src/components/providers/ThemeProvider.tsx +5 -0
  124. package/src/desktop/desktop-update.types.ts +25 -0
  125. package/src/desktop/managers/desktop-presence.manager.ts +91 -0
  126. package/src/desktop/managers/desktop-update.manager.ts +37 -1
  127. package/src/desktop/stores/desktop-presence.store.ts +18 -0
  128. package/src/desktop/stores/desktop-update.store.ts +7 -1
  129. package/src/hooks/server-path/use-server-path-read.ts +20 -0
  130. package/src/hooks/use-runtime-control.ts +24 -0
  131. package/src/lib/chat-message.ts +14 -3
  132. package/src/lib/desktop-update-labels.utils.ts +28 -2
  133. package/src/lib/i18n.chat.ts +12 -1
  134. package/src/lib/i18n.pwa.ts +62 -0
  135. package/src/lib/i18n.runtime-control.ts +120 -0
  136. package/src/lib/i18n.ts +4 -6
  137. package/src/main.tsx +1 -1
  138. package/src/pwa/components/pwa-install-entry.test.tsx +110 -0
  139. package/src/pwa/components/pwa-install-entry.tsx +205 -0
  140. package/src/pwa/managers/pwa-install.manager.test.ts +160 -0
  141. package/src/pwa/managers/pwa-install.manager.ts +232 -0
  142. package/src/pwa/managers/pwa-runtime.manager.ts +196 -0
  143. package/src/pwa/managers/pwa-shell-theme.manager.test.ts +30 -0
  144. package/src/pwa/managers/pwa-shell-theme.manager.ts +46 -0
  145. package/src/pwa/pwa-install-banner.storage.ts +55 -0
  146. package/src/pwa/pwa.types.ts +22 -0
  147. package/src/pwa/register-pwa.ts +14 -0
  148. package/src/pwa/stores/pwa.store.ts +17 -0
  149. package/src/runtime-control/runtime-control.manager.ts +118 -0
  150. package/src/vite-env.d.ts +9 -0
  151. package/dist/assets/ChannelsList-D8p4OlM6.js +0 -8
  152. package/dist/assets/ChatPage-A45t1Rmf.js +0 -58
  153. package/dist/assets/DocBrowser-B2MpsnU9.js +0 -1
  154. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +0 -1
  155. package/dist/assets/McpMarketplacePage-CxPFOgxv.js +0 -40
  156. package/dist/assets/RemoteAccessPage-DyYVWsyK.js +0 -1
  157. package/dist/assets/RuntimeConfig-ChdfK4Y_.js +0 -1
  158. package/dist/assets/chat-session-display-CAlPrnlV.js +0 -1
  159. package/dist/assets/desktop-update-config-CfoVwf-w.js +0 -1
  160. package/dist/assets/i18n-C3jb83S6.js +0 -1
  161. package/dist/assets/index-CE4N7ItL.css +0 -1
  162. package/dist/assets/index-riX7Sg0_.js +0 -6
  163. package/dist/assets/loader-circle-BjMg63eu.js +0 -1
  164. package/dist/assets/plus-CIXME2pD.js +0 -1
  165. package/dist/assets/search-B_Qr0f6C.js +0 -1
  166. package/dist/assets/skeleton-CYQJazv6.js +0 -1
  167. package/dist/assets/x-B8Tho_xC.js +0 -1
  168. package/src/components/chat/ChatConversationPanel.tsx +0 -256
  169. package/src/components/chat/chat-child-session-panel.tsx +0 -262
  170. /package/dist/assets/{config-hints-GSUMvmSo.js → config-hints-BhTmc9P1.js} +0 -0
  171. /package/dist/assets/{config-layout-CgBMG7OL.js → config-layout-CHs0mAaR.js} +0 -0
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
2
  import { useConfig, useConfigSchema, useUpdateRuntime } from '@/hooks/useConfig';
3
- import type { AgentBindingView, AgentProfileView } from '@/api/types';
3
+ import type { AgentBindingView, AgentProfileView, RuntimeEntryView } from '@/api/types';
4
4
  import { Button } from '@/components/ui/button';
5
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
6
+ import { RuntimeControlCard } from '@/components/config/runtime-control-card';
7
+ import { RuntimePresenceCard } from '@/components/config/runtime-presence-card';
6
8
  import { Input } from '@/components/ui/input';
7
9
  import { Switch } from '@/components/ui/switch';
8
10
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -17,11 +19,16 @@ import {
17
19
  import { hintForPath } from '@/lib/config-hints';
18
20
  import { t } from '@/lib/i18n';
19
21
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
22
+ import { PwaInstallCard } from '@/pwa/components/pwa-install-entry';
20
23
  import { Plus, Save, Trash2 } from 'lucide-react';
21
24
  import { toast } from 'sonner';
22
25
 
23
26
  type DmScope = 'main' | 'per-peer' | 'per-channel-peer' | 'per-account-channel-peer';
24
27
  type PeerKind = '' | 'direct' | 'group' | 'channel';
28
+ type RuntimeEntryDraft = RuntimeEntryView & {
29
+ id: string;
30
+ configText: string;
31
+ };
25
32
 
26
33
  const DM_SCOPE_OPTIONS: Array<{ value: DmScope; label: string }> = [
27
34
  { value: 'main', label: 'main' },
@@ -30,6 +37,29 @@ const DM_SCOPE_OPTIONS: Array<{ value: DmScope; label: string }> = [
30
37
  { value: 'per-account-channel-peer', label: 'per-account-channel-peer' }
31
38
  ];
32
39
 
40
+ const DEFAULT_NARP_STDIO_ENTRY_CONFIG = {
41
+ wireDialect: 'acp',
42
+ processScope: 'per-session',
43
+ command: '',
44
+ args: ['acp'],
45
+ env: {},
46
+ cwd: '',
47
+ startupTimeoutMs: 8000,
48
+ probeTimeoutMs: 3000,
49
+ requestTimeoutMs: 120000
50
+ };
51
+
52
+ function RuntimeConfigOverview() {
53
+ return (
54
+ <>
55
+ <PageHeader title={t('runtimePageTitle')} description={t('runtimePageDescription')} />
56
+ <RuntimeControlCard />
57
+ <RuntimePresenceCard />
58
+ <PwaInstallCard />
59
+ </>
60
+ );
61
+ }
62
+
33
63
  export function RuntimeConfig() {
34
64
  const { data: config, isLoading } = useConfig();
35
65
  const { data: schema } = useConfigSchema();
@@ -37,6 +67,7 @@ export function RuntimeConfig() {
37
67
 
38
68
  const [agents, setAgents] = useState<AgentProfileView[]>([]);
39
69
  const [bindings, setBindings] = useState<AgentBindingView[]>([]);
70
+ const [runtimeEntries, setRuntimeEntries] = useState<RuntimeEntryDraft[]>([]);
40
71
  const [dmScope, setDmScope] = useState<DmScope>('per-channel-peer');
41
72
  const [defaultContextTokens, setDefaultContextTokens] = useState(200000);
42
73
  const [defaultEngine, setDefaultEngine] = useState('native');
@@ -47,6 +78,16 @@ export function RuntimeConfig() {
47
78
  }
48
79
  setAgents((config.agents.list ?? []).map(hydrateRuntimeAgent));
49
80
  setBindings((config.bindings ?? []).map(hydrateRuntimeBinding));
81
+ setRuntimeEntries(
82
+ Object.entries(config.agents.runtimes?.entries ?? {}).map(([id, entry]) => ({
83
+ id,
84
+ enabled: entry.enabled !== false,
85
+ label: entry.label ?? '',
86
+ type: entry.type,
87
+ config: entry.config ?? {},
88
+ configText: JSON.stringify(entry.config ?? {}, null, 2)
89
+ }))
90
+ );
50
91
  setDmScope((config.session?.dmScope as DmScope) ?? 'per-channel-peer');
51
92
  setDefaultContextTokens(config.agents.defaults.contextTokens ?? 200000);
52
93
  setDefaultEngine(config.agents.defaults.engine ?? 'native');
@@ -60,6 +101,7 @@ export function RuntimeConfig() {
60
101
  const agentEngineHint = hintForPath('agents.list.*.engine', uiHints);
61
102
  const agentsHint = hintForPath('agents.list', uiHints);
62
103
  const bindingsHint = hintForPath('bindings', uiHints);
104
+ const runtimeEntriesHint = hintForPath('agents.runtimes.entries', uiHints);
63
105
 
64
106
  const knownAgentIds = useMemo(() => {
65
107
  const ids = new Set<string>(['main']);
@@ -80,6 +122,28 @@ export function RuntimeConfig() {
80
122
  setBindings((prev) => prev.map((binding, cursor) => (cursor === index ? next : binding)));
81
123
  };
82
124
 
125
+ const updateRuntimeEntry = (index: number, patch: Partial<RuntimeEntryDraft>) => {
126
+ setRuntimeEntries((prev) => prev.map((entry, cursor) => (cursor === index ? { ...entry, ...patch } : entry)));
127
+ };
128
+
129
+ const removeRuntimeEntry = (index: number) => {
130
+ setRuntimeEntries((prev) => prev.filter((_, cursor) => cursor !== index));
131
+ };
132
+
133
+ const addRuntimeEntry = () => {
134
+ setRuntimeEntries((prev) => [
135
+ ...prev,
136
+ {
137
+ id: '',
138
+ enabled: true,
139
+ label: '',
140
+ type: 'narp-stdio',
141
+ config: DEFAULT_NARP_STDIO_ENTRY_CONFIG,
142
+ configText: JSON.stringify(DEFAULT_NARP_STDIO_ENTRY_CONFIG, null, 2)
143
+ }
144
+ ]);
145
+ };
146
+
83
147
  const handleSave = () => {
84
148
  try {
85
149
  const normalizedAgents = agents.map((agent, index) => {
@@ -139,6 +203,33 @@ export function RuntimeConfig() {
139
203
  return normalized;
140
204
  });
141
205
 
206
+ const normalizedRuntimeEntries = runtimeEntries.reduce<Record<string, RuntimeEntryView>>((entries, entry, index) => {
207
+ const id = entry.id.trim();
208
+ const type = entry.type.trim();
209
+ if (!id) {
210
+ throw new Error(`Runtime entry id is required at index ${index}.`);
211
+ }
212
+ if (!type) {
213
+ throw new Error(`Runtime entry type is required for "${id}".`);
214
+ }
215
+ if (entries[id]) {
216
+ throw new Error(`Duplicate runtime entry id: ${id}`);
217
+ }
218
+
219
+ const configValue = entry.configText.trim() ? JSON.parse(entry.configText) : {};
220
+ if (configValue && (typeof configValue !== 'object' || Array.isArray(configValue))) {
221
+ throw new Error(`Runtime entry config for "${id}" must be a JSON object.`);
222
+ }
223
+
224
+ entries[id] = {
225
+ enabled: entry.enabled !== false,
226
+ ...(entry.label?.trim() ? { label: entry.label.trim() } : {}),
227
+ type,
228
+ config: (configValue as Record<string, unknown>) ?? {}
229
+ };
230
+ return entries;
231
+ }, {});
232
+
142
233
  updateRuntime.mutate({
143
234
  data: {
144
235
  agents: {
@@ -146,7 +237,10 @@ export function RuntimeConfig() {
146
237
  contextTokens: Math.max(1000, defaultContextTokens),
147
238
  engine: defaultEngine.trim() || 'native'
148
239
  },
149
- list: normalizedAgents
240
+ list: normalizedAgents,
241
+ runtimes: {
242
+ entries: normalizedRuntimeEntries
243
+ }
150
244
  },
151
245
  bindings: normalizedBindings,
152
246
  session: {
@@ -160,14 +254,11 @@ export function RuntimeConfig() {
160
254
  }
161
255
  };
162
256
 
163
- if (isLoading || !config) {
164
- return <div className="p-8 text-gray-400">{t('runtimeLoading')}</div>;
165
- }
257
+ if (isLoading || !config) return <div className="p-8 text-gray-400">{t('runtimeLoading')}</div>;
166
258
 
167
259
  return (
168
260
  <PageLayout className="space-y-6">
169
- <PageHeader title={t('runtimePageTitle')} description={t('runtimePageDescription')} />
170
-
261
+ <RuntimeConfigOverview />
171
262
  <Card>
172
263
  <CardHeader>
173
264
  <CardTitle>{dmScopeHint?.label ?? t('dmScope')}</CardTitle>
@@ -220,6 +311,62 @@ export function RuntimeConfig() {
220
311
  </CardContent>
221
312
  </Card>
222
313
 
314
+ <Card>
315
+ <CardHeader>
316
+ <CardTitle>{runtimeEntriesHint?.label ?? 'Runtime Entries'}</CardTitle>
317
+ <CardDescription>{runtimeEntriesHint?.help ?? '统一管理可见的 runtime entry 与其配置。'}</CardDescription>
318
+ </CardHeader>
319
+ <CardContent className="space-y-3">
320
+ {runtimeEntries.map((entry, index) => (
321
+ <div key={`${index}-${entry.id || 'runtime-entry'}`} className="rounded-xl border border-gray-200 p-3 space-y-3">
322
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
323
+ <Input
324
+ value={entry.id}
325
+ onChange={(event) => updateRuntimeEntry(index, { id: event.target.value })}
326
+ placeholder="entry id,例如 hermes"
327
+ />
328
+ <Input
329
+ value={entry.label ?? ''}
330
+ onChange={(event) => updateRuntimeEntry(index, { label: event.target.value })}
331
+ placeholder="展示名称,例如 Hermes"
332
+ />
333
+ <Input
334
+ value={entry.type}
335
+ onChange={(event) => updateRuntimeEntry(index, { type: event.target.value })}
336
+ placeholder="runtime type,例如 narp-stdio"
337
+ />
338
+ <div className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2">
339
+ <span className="text-sm text-gray-700">Enabled</span>
340
+ <Switch
341
+ checked={entry.enabled !== false}
342
+ onCheckedChange={(checked) => updateRuntimeEntry(index, { enabled: checked })}
343
+ />
344
+ </div>
345
+ </div>
346
+ <div className="space-y-2">
347
+ <label className="text-sm font-medium text-gray-800">Config JSON</label>
348
+ <textarea
349
+ className="min-h-32 w-full rounded-md border border-gray-200 px-3 py-2 text-sm font-mono"
350
+ value={entry.configText}
351
+ onChange={(event) => updateRuntimeEntry(index, { configText: event.target.value })}
352
+ spellCheck={false}
353
+ />
354
+ </div>
355
+ <div className="flex justify-end">
356
+ <Button type="button" variant="outline" onClick={() => removeRuntimeEntry(index)}>
357
+ <Trash2 className="mr-2 h-4 w-4" />
358
+ {t('deleteButton')}
359
+ </Button>
360
+ </div>
361
+ </div>
362
+ ))}
363
+ <Button type="button" variant="outline" onClick={addRuntimeEntry}>
364
+ <Plus className="mr-2 h-4 w-4" />
365
+ Add Runtime Entry
366
+ </Button>
367
+ </CardContent>
368
+ </Card>
369
+
223
370
  <Card>
224
371
  <CardHeader>
225
372
  <CardTitle>{agentsHint?.label ?? t('agentList')}</CardTitle>
@@ -0,0 +1,85 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { DesktopUpdateConfig } from '@/components/config/desktop-update-config';
5
+ import { useDesktopUpdateStore } from '@/desktop/stores/desktop-update.store';
6
+ import { setLanguage } from '@/lib/i18n';
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ start: vi.fn(),
10
+ stop: vi.fn(),
11
+ checkForUpdates: vi.fn(),
12
+ downloadUpdate: vi.fn(),
13
+ applyDownloadedUpdate: vi.fn(),
14
+ updatePreferences: vi.fn(),
15
+ updateChannel: vi.fn()
16
+ }));
17
+
18
+ vi.mock('@/desktop/managers/desktop-update.manager', () => ({
19
+ desktopUpdateManager: mocks
20
+ }));
21
+
22
+ describe('DesktopUpdateConfig', () => {
23
+ beforeEach(() => {
24
+ setLanguage('zh');
25
+ mocks.start.mockReset();
26
+ mocks.stop.mockReset();
27
+ mocks.checkForUpdates.mockReset();
28
+ mocks.downloadUpdate.mockReset();
29
+ mocks.applyDownloadedUpdate.mockReset();
30
+ mocks.updatePreferences.mockReset();
31
+ mocks.updateChannel.mockReset();
32
+
33
+ if (!HTMLElement.prototype.hasPointerCapture) {
34
+ HTMLElement.prototype.hasPointerCapture = () => false;
35
+ }
36
+ if (!HTMLElement.prototype.setPointerCapture) {
37
+ HTMLElement.prototype.setPointerCapture = () => {};
38
+ }
39
+ if (!HTMLElement.prototype.releasePointerCapture) {
40
+ HTMLElement.prototype.releasePointerCapture = () => {};
41
+ }
42
+
43
+ useDesktopUpdateStore.setState({
44
+ supported: true,
45
+ initialized: true,
46
+ busyAction: null,
47
+ snapshot: {
48
+ status: 'idle',
49
+ channel: 'beta',
50
+ launcherVersion: '0.0.138',
51
+ currentVersion: '0.18.0',
52
+ availableVersion: '0.18.2-beta.1',
53
+ downloadedVersion: null,
54
+ releaseNotesUrl: 'https://example.com/release-notes',
55
+ lastCheckedAt: '2026-04-13T12:00:00.000Z',
56
+ errorMessage: null,
57
+ preferences: {
58
+ automaticChecks: true,
59
+ autoDownload: false
60
+ }
61
+ }
62
+ });
63
+ });
64
+
65
+ it('renders current channel information and beta guidance', () => {
66
+ render(<DesktopUpdateConfig />);
67
+
68
+ expect(mocks.start).toHaveBeenCalledTimes(1);
69
+ expect(screen.getByText('当前更新通道')).toBeTruthy();
70
+ expect(screen.getAllByText('Beta').length).toBeGreaterThan(0);
71
+ expect(screen.getByText('当前正在跟随 Beta 通道')).toBeTruthy();
72
+ expect(screen.getByText('切回 Stable 后不会立刻强制降级;只有当 Stable 追平或超过当前版本时,才会继续提供 Stable 更新。')).toBeTruthy();
73
+ });
74
+
75
+ it('sends the newly selected release channel to the desktop update manager', async () => {
76
+ const user = userEvent.setup();
77
+
78
+ render(<DesktopUpdateConfig />);
79
+
80
+ await user.click(screen.getByRole('combobox'));
81
+ await user.click(screen.getByRole('option', { name: 'Stable' }));
82
+
83
+ expect(mocks.updateChannel).toHaveBeenCalledWith('stable');
84
+ });
85
+ });
@@ -2,9 +2,11 @@ import { useEffect } from 'react';
2
2
  import { Button } from '@/components/ui/button';
3
3
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
4
4
  import { Label } from '@/components/ui/label';
5
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
5
6
  import { Switch } from '@/components/ui/switch';
6
7
  import { PageHeader, PageLayout } from '@/components/layout/page-layout';
7
8
  import { desktopUpdateManager } from '@/desktop/managers/desktop-update.manager';
9
+ import type { DesktopReleaseChannel } from '@/desktop/desktop-update.types';
8
10
  import { useDesktopUpdateStore } from '@/desktop/stores/desktop-update.store';
9
11
  import { formatDateTime, t } from '@/lib/i18n';
10
12
  import { cn } from '@/lib/utils';
@@ -18,6 +20,10 @@ function formatLastCheckedAt(value: string | null): string {
18
20
  return value ? formatDateTime(value) : '-';
19
21
  }
20
22
 
23
+ function getChannelLabel(channel: DesktopReleaseChannel): string {
24
+ return channel === 'beta' ? t('desktopUpdatesChannelBeta') : t('desktopUpdatesChannelStable');
25
+ }
26
+
21
27
  function getStatusLabel(status: string): string {
22
28
  if (status === 'checking') {
23
29
  return t('desktopUpdatesStatusChecking');
@@ -91,6 +97,7 @@ export function DesktopUpdateConfig() {
91
97
  const isDownloading = busyAction === 'downloading';
92
98
  const isApplying = busyAction === 'applying';
93
99
  const isSavingPreferences = busyAction === 'saving-preferences';
100
+ const isSwitchingChannel = busyAction === 'switching-channel';
94
101
  const canDownload = snapshot.status === 'update-available' && !isDownloading && !isApplying;
95
102
  const canApply = snapshot.status === 'downloaded' && !isApplying;
96
103
 
@@ -124,7 +131,7 @@ export function DesktopUpdateConfig() {
124
131
  </span>
125
132
  </div>
126
133
 
127
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
134
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
128
135
  <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
129
136
  <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesLauncherVersion')}</p>
130
137
  <p className="mt-2 text-base font-semibold text-gray-900">{formatVersion(snapshot.launcherVersion)}</p>
@@ -141,8 +148,19 @@ export function DesktopUpdateConfig() {
141
148
  <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesLastCheckedAt')}</p>
142
149
  <p className="mt-2 text-base font-semibold text-gray-900">{formatLastCheckedAt(snapshot.lastCheckedAt)}</p>
143
150
  </div>
151
+ <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
152
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesCurrentChannel')}</p>
153
+ <p className="mt-2 text-base font-semibold text-gray-900">{getChannelLabel(snapshot.channel)}</p>
154
+ </div>
144
155
  </div>
145
156
 
157
+ {snapshot.channel === 'beta' ? (
158
+ <div className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4">
159
+ <p className="text-sm font-semibold text-amber-800">{t('desktopUpdatesBetaBadgeTitle')}</p>
160
+ <p className="mt-1 text-sm text-amber-700">{t('desktopUpdatesBetaBadgeDescription')}</p>
161
+ </div>
162
+ ) : null}
163
+
146
164
  {snapshot.downloadedVersion ? (
147
165
  <div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4">
148
166
  <p className="text-sm font-semibold text-emerald-800">{t('desktopUpdatesDownloadedBannerTitle')}</p>
@@ -166,6 +184,29 @@ export function DesktopUpdateConfig() {
166
184
  <CardDescription>{t('desktopUpdatesPreferencesDescription')}</CardDescription>
167
185
  </CardHeader>
168
186
  <CardContent className="space-y-5">
187
+ <div className="rounded-xl border border-gray-200 p-4">
188
+ <div className="space-y-3">
189
+ <div className="space-y-1">
190
+ <Label>{t('desktopUpdatesReleaseChannel')}</Label>
191
+ <p className="text-sm text-gray-500">{t('desktopUpdatesReleaseChannelHelp')}</p>
192
+ </div>
193
+ <Select
194
+ value={snapshot.channel}
195
+ disabled={isSwitchingChannel || isChecking || isDownloading || isApplying}
196
+ onValueChange={(value) => void desktopUpdateManager.updateChannel(value as DesktopReleaseChannel)}
197
+ >
198
+ <SelectTrigger className="w-full max-w-sm">
199
+ <SelectValue placeholder={t('desktopUpdatesReleaseChannel')} />
200
+ </SelectTrigger>
201
+ <SelectContent>
202
+ <SelectItem value="stable">{t('desktopUpdatesChannelStable')}</SelectItem>
203
+ <SelectItem value="beta">{t('desktopUpdatesChannelBeta')}</SelectItem>
204
+ </SelectContent>
205
+ </Select>
206
+ <p className="text-sm text-gray-500">{t('desktopUpdatesReleaseChannelDowngradeHint')}</p>
207
+ </div>
208
+ </div>
209
+
169
210
  <div className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 p-4">
170
211
  <div className="space-y-1">
171
212
  <Label>{t('desktopUpdatesAutomaticChecks')}</Label>
@@ -173,7 +214,7 @@ export function DesktopUpdateConfig() {
173
214
  </div>
174
215
  <Switch
175
216
  checked={snapshot.preferences.automaticChecks}
176
- disabled={isSavingPreferences}
217
+ disabled={isSavingPreferences || isSwitchingChannel}
177
218
  onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ automaticChecks: checked })}
178
219
  />
179
220
  </div>
@@ -185,7 +226,7 @@ export function DesktopUpdateConfig() {
185
226
  </div>
186
227
  <Switch
187
228
  checked={snapshot.preferences.autoDownload}
188
- disabled={isSavingPreferences}
229
+ disabled={isSavingPreferences || isSwitchingChannel}
189
230
  onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ autoDownload: checked })}
190
231
  />
191
232
  </div>
@@ -0,0 +1,255 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import type { ReactNode } from 'react';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { toast } from 'sonner';
7
+ import { RuntimeControlCard } from '@/components/config/runtime-control-card';
8
+ import { setLanguage } from '@/lib/i18n';
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ useRuntimeControl: vi.fn(),
12
+ useRuntimeServiceAction: vi.fn(),
13
+ waitForRecovery: vi.fn(),
14
+ restartApp: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('sonner', () => ({
18
+ toast: {
19
+ success: vi.fn(),
20
+ error: vi.fn(),
21
+ },
22
+ }));
23
+
24
+ vi.mock('@/hooks/use-runtime-control', () => ({
25
+ useRuntimeControl: (...args: unknown[]) => mocks.useRuntimeControl(...args),
26
+ useRuntimeServiceAction: (...args: unknown[]) => mocks.useRuntimeServiceAction(...args),
27
+ }));
28
+
29
+ vi.mock('@/runtime-control/runtime-control.manager', () => ({
30
+ runtimeControlManager: {
31
+ waitForRecovery: (...args: unknown[]) => mocks.waitForRecovery(...args),
32
+ restartApp: (...args: unknown[]) => mocks.restartApp(...args),
33
+ },
34
+ }));
35
+
36
+ function createWrapper(queryClient: QueryClient) {
37
+ return function Wrapper({ children }: { children: ReactNode }) {
38
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
39
+ };
40
+ }
41
+
42
+ describe('RuntimeControlCard', () => {
43
+ beforeEach(() => {
44
+ setLanguage('zh');
45
+ vi.clearAllMocks();
46
+ mocks.useRuntimeControl.mockReturnValue({
47
+ data: {
48
+ environment: 'managed-local-service',
49
+ lifecycle: 'healthy',
50
+ serviceState: 'running',
51
+ message: 'runtime healthy',
52
+ canStartService: {
53
+ available: false,
54
+ requiresConfirmation: false,
55
+ impact: 'brief-ui-disconnect',
56
+ reasonIfUnavailable: '当前页面已经由运行中的本地服务托管。'
57
+ },
58
+ canRestartService: {
59
+ available: true,
60
+ requiresConfirmation: false,
61
+ impact: 'brief-ui-disconnect',
62
+ },
63
+ canStopService: {
64
+ available: true,
65
+ requiresConfirmation: true,
66
+ impact: 'brief-ui-disconnect',
67
+ },
68
+ canRestartApp: {
69
+ available: false,
70
+ requiresConfirmation: true,
71
+ impact: 'full-app-relaunch',
72
+ reasonIfUnavailable: 'desktop only',
73
+ },
74
+ managementHint: 'This page is served by the running local service.'
75
+ },
76
+ isError: false,
77
+ error: null,
78
+ });
79
+ mocks.useRuntimeServiceAction.mockReturnValue({
80
+ mutateAsync: vi.fn().mockResolvedValue({
81
+ accepted: true,
82
+ action: 'restart-service',
83
+ lifecycle: 'restarting-service',
84
+ message: 'Restart scheduled. This page may disconnect for a few seconds.',
85
+ }),
86
+ isPending: false,
87
+ });
88
+ mocks.waitForRecovery.mockResolvedValue({
89
+ environment: 'managed-local-service',
90
+ lifecycle: 'healthy',
91
+ serviceState: 'running',
92
+ message: 'runtime healthy',
93
+ canStartService: {
94
+ available: false,
95
+ requiresConfirmation: false,
96
+ impact: 'brief-ui-disconnect',
97
+ reasonIfUnavailable: '当前页面已经由运行中的本地服务托管。'
98
+ },
99
+ canRestartService: {
100
+ available: true,
101
+ requiresConfirmation: false,
102
+ impact: 'brief-ui-disconnect',
103
+ },
104
+ canStopService: {
105
+ available: true,
106
+ requiresConfirmation: true,
107
+ impact: 'brief-ui-disconnect',
108
+ },
109
+ canRestartApp: {
110
+ available: false,
111
+ requiresConfirmation: true,
112
+ impact: 'full-app-relaunch',
113
+ reasonIfUnavailable: 'desktop only',
114
+ },
115
+ managementHint: 'This page is served by the running local service.'
116
+ });
117
+ mocks.restartApp.mockResolvedValue({
118
+ accepted: true,
119
+ action: 'restart-app',
120
+ lifecycle: 'restarting-app',
121
+ message: 'NextClaw app restart scheduled.',
122
+ });
123
+ });
124
+
125
+ afterEach(() => {
126
+ vi.restoreAllMocks();
127
+ });
128
+
129
+ it('renders service management actions from the current capability view', () => {
130
+ const queryClient = new QueryClient();
131
+
132
+ render(<RuntimeControlCard />, {
133
+ wrapper: createWrapper(queryClient),
134
+ });
135
+
136
+ const startButton = screen.getByRole('button', { name: '启动服务' }) as HTMLButtonElement;
137
+ const restartAppButton = screen.getByRole('button', { name: '重启应用' }) as HTMLButtonElement;
138
+ expect(screen.getByText('服务管理')).toBeTruthy();
139
+ expect(screen.getByText('服务运行中')).toBeTruthy();
140
+ expect(screen.getByRole('button', { name: '重启服务' })).toBeTruthy();
141
+ expect(screen.getByRole('button', { name: '停止服务' })).toBeTruthy();
142
+ expect(startButton.disabled).toBe(true);
143
+ expect(restartAppButton.disabled).toBe(true);
144
+ expect(screen.getByText('desktop only')).toBeTruthy();
145
+ });
146
+
147
+ it('runs the restart service flow and waits for recovery', async () => {
148
+ const queryClient = new QueryClient();
149
+ const user = userEvent.setup();
150
+ const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
151
+ const mutateAsync = vi.fn().mockResolvedValue({
152
+ accepted: true,
153
+ action: 'restart-service',
154
+ lifecycle: 'restarting-service',
155
+ message: 'Restart scheduled. This page may disconnect for a few seconds.',
156
+ });
157
+ mocks.useRuntimeServiceAction.mockReturnValue({
158
+ mutateAsync,
159
+ isPending: false,
160
+ });
161
+
162
+ render(<RuntimeControlCard />, {
163
+ wrapper: createWrapper(queryClient),
164
+ });
165
+
166
+ await user.click(screen.getByRole('button', { name: '重启服务' }));
167
+
168
+ await waitFor(() => {
169
+ expect(mutateAsync).toHaveBeenCalledWith('restart-service');
170
+ expect(mocks.waitForRecovery).toHaveBeenCalledTimes(1);
171
+ });
172
+ expect(toast.success).toHaveBeenCalledWith('Restart scheduled. This page may disconnect for a few seconds.');
173
+ expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['runtime-control'] });
174
+ });
175
+
176
+ it('runs the stop service flow after confirmation', async () => {
177
+ const queryClient = new QueryClient();
178
+ const user = userEvent.setup();
179
+ const mutateAsync = vi.fn().mockResolvedValue({
180
+ accepted: true,
181
+ action: 'stop-service',
182
+ lifecycle: 'stopping-service',
183
+ message: 'Stop scheduled. This page will disconnect shortly.',
184
+ });
185
+ mocks.useRuntimeServiceAction.mockReturnValue({
186
+ mutateAsync,
187
+ isPending: false,
188
+ });
189
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
190
+
191
+ render(<RuntimeControlCard />, {
192
+ wrapper: createWrapper(queryClient),
193
+ });
194
+
195
+ await user.click(screen.getByRole('button', { name: '停止服务' }));
196
+
197
+ await waitFor(() => {
198
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
199
+ expect(mutateAsync).toHaveBeenCalledWith('stop-service');
200
+ });
201
+ expect(mocks.waitForRecovery).not.toHaveBeenCalled();
202
+ expect(toast.success).toHaveBeenCalledWith('Stop scheduled. This page will disconnect shortly.');
203
+ });
204
+
205
+ it('runs the desktop restart app flow after confirmation', async () => {
206
+ const queryClient = new QueryClient();
207
+ const user = userEvent.setup();
208
+
209
+ mocks.useRuntimeControl.mockReturnValue({
210
+ data: {
211
+ environment: 'desktop-embedded',
212
+ lifecycle: 'healthy',
213
+ serviceState: 'running',
214
+ message: 'runtime healthy',
215
+ canStartService: {
216
+ available: false,
217
+ requiresConfirmation: false,
218
+ impact: 'none',
219
+ },
220
+ canRestartService: {
221
+ available: true,
222
+ requiresConfirmation: false,
223
+ impact: 'brief-ui-disconnect',
224
+ },
225
+ canStopService: {
226
+ available: false,
227
+ requiresConfirmation: true,
228
+ impact: 'brief-ui-disconnect',
229
+ },
230
+ canRestartApp: {
231
+ available: true,
232
+ requiresConfirmation: true,
233
+ impact: 'full-app-relaunch',
234
+ },
235
+ managementHint: 'desktop launcher hint'
236
+ },
237
+ isError: false,
238
+ error: null,
239
+ });
240
+
241
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
242
+
243
+ render(<RuntimeControlCard />, {
244
+ wrapper: createWrapper(queryClient),
245
+ });
246
+
247
+ await user.click(screen.getByRole('button', { name: '重启应用' }));
248
+
249
+ await waitFor(() => {
250
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
251
+ expect(mocks.restartApp).toHaveBeenCalledTimes(1);
252
+ });
253
+ expect(toast.success).toHaveBeenCalledWith('NextClaw app restart scheduled.');
254
+ });
255
+ });