@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.
- package/CHANGELOG.md +85 -0
- package/dist/assets/ChannelsList-Ita2Zm1_.js +8 -0
- package/dist/assets/{DocBrowser-Cse_F8Nn.js → DocBrowser-6ReNjvzF.js} +1 -1
- package/dist/assets/DocBrowser-BNwbPHf4.js +1 -0
- package/dist/assets/{DocBrowserContext-Bai1WU2H.js → DocBrowserContext-B6SpA7Qs.js} +1 -1
- package/dist/assets/{LogoBadge-BdxMPc9v.js → LogoBadge-ByNLYg65.js} +1 -1
- package/dist/assets/MarketplacePage-CjX2MWww.js +1 -0
- package/dist/assets/{MarketplacePage-BbpAkllU.js → MarketplacePage-D0sDlYX4.js} +1 -1
- package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +40 -0
- package/dist/assets/{ModelConfig-3GLqQ5GY.js → ModelConfig-BzZenCH-.js} +1 -1
- package/dist/assets/{ProviderScopedModelInput-BYNouw-i.js → ProviderScopedModelInput-Da7khnBA.js} +1 -1
- package/dist/assets/{ProvidersList-BR1gJ4Dm.js → ProvidersList-BbVzRxjY.js} +1 -1
- package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +1 -0
- package/dist/assets/RuntimeConfig-F_XKGgLm.js +1 -0
- package/dist/assets/{SearchConfig-DTeJvp8m.js → SearchConfig-BGkzXQP-.js} +1 -1
- package/dist/assets/{SecretsConfig-CCYO6NcV.js → SecretsConfig-D281Rotl.js} +2 -2
- package/dist/assets/{SessionsConfig-Du39vDgt.js → SessionsConfig-ChHQ7M5c.js} +2 -2
- package/dist/assets/{app-query-client-Dr5d-K8d.js → app-query-client-VnFElj4E.js} +1 -1
- package/dist/assets/{book-open-Da4OEPqB.js → book-open-BdcxxoQu.js} +1 -1
- package/dist/assets/chat-page-Doe0yTtB.js +58 -0
- package/dist/assets/chat-session-display-cw78aiI_.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-CoFVxHXV.js → chunk-JZWAC4HX-DK5HPmIK.js} +1 -1
- package/dist/assets/{client-CSk58DcF.js → client-_i4MU2bB.js} +1 -1
- package/dist/assets/{config-D8KzikVB.js → config-DtIQwrHF.js} +1 -1
- package/dist/assets/{createLucideIcon-83gaZMtv.js → createLucideIcon-BSeTgkZW.js} +1 -1
- package/dist/assets/desktop-update-config-Dpcf4BKG.js +1 -0
- package/dist/assets/{dist-toEYs-MZ.js → dist-6TrrnPCR.js} +1 -1
- package/dist/assets/{dist-aTmhMDVh.js → dist-ccBFUi-o.js} +1 -1
- package/dist/assets/download-BhDxnyvU.js +1 -0
- package/dist/assets/{external-link-QQ0TC6X4.js → external-link-BgErLCNT.js} +1 -1
- package/dist/assets/{hash-DaFBEkmi.js → hash-Bl7dr_UG.js} +1 -1
- package/dist/assets/i18n-eDHeDY0n.js +1 -0
- package/dist/assets/index-CF9xve0E.js +6 -0
- package/dist/assets/index-FgA52VBt.css +1 -0
- package/dist/assets/{infiniteQueryBehavior-BmHX_ayZ.js → infiniteQueryBehavior-ZDS92Qpp.js} +1 -1
- package/dist/assets/loader-circle-ACM1s51e.js +1 -0
- package/dist/assets/{logos-Dzlz30M3.js → logos-x89HbrZ4.js} +1 -1
- package/dist/assets/{page-layout-D2eRufRQ.js → page-layout-vZnghcFy.js} +1 -1
- package/dist/assets/play-CFUwCA2E.js +1 -0
- package/dist/assets/plus-rYsv72JG.js +1 -0
- package/dist/assets/{popover-BSXxm5bj.js → popover-Bg1VoTZ6.js} +1 -1
- package/dist/assets/{refresh-ccw-B3zMtN-_.js → refresh-ccw-DT98i__E.js} +1 -1
- package/dist/assets/{refresh-cw-DlZkIHnJ.js → refresh-cw-C47QSEwg.js} +1 -1
- package/dist/assets/rotate-cw-JtFzpNn6.js +1 -0
- package/dist/assets/{save-Us9fg4Sj.js → save-3S6-H3Xw.js} +1 -1
- package/dist/assets/search-3kFR_zh9.js +1 -0
- package/dist/assets/{security-config-BGWYwxNr.js → security-config-BWaiARNk.js} +1 -1
- package/dist/assets/{select-DLYqySQK.js → select-DJ2MUjBB.js} +1 -1
- package/dist/assets/skeleton-ByQepn0M.js +1 -0
- package/dist/assets/{status-dot-DGayudyB.js → status-dot-vbanNPFU.js} +1 -1
- package/dist/assets/{switch-Dz2ScsKx.js → switch-BsLtHOH-.js} +1 -1
- package/dist/assets/{tabs-custom-CdKyjiGk.js → tabs-custom-D3HYMt6k.js} +1 -1
- package/dist/assets/{trash-2-Db-mZOZs.js → trash-2-G48scll7.js} +1 -1
- package/dist/assets/{use-infinite-scroll-loader-DBJX5hj0.js → use-infinite-scroll-loader-DkNhD-42.js} +1 -1
- package/dist/assets/{useConfirmDialog-DL0a-oGC.js → useConfirmDialog-BkvTN-vd.js} +1 -1
- package/dist/assets/{useMutation-BdZm-9PL.js → useMutation-CBWjE2uj.js} +1 -1
- package/dist/assets/x-ByDbItbq.js +1 -0
- package/dist/index.html +95 -21
- package/dist/manifest.webmanifest +30 -0
- package/dist/offline.html +102 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/sw.js +80 -0
- package/index.html +73 -1
- package/package.json +6 -6
- package/public/manifest.webmanifest +30 -0
- package/public/offline.html +102 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/sw.js +80 -0
- package/src/api/runtime-control.ts +34 -0
- package/src/api/runtime-control.types.ts +58 -0
- package/src/api/server-path.ts +27 -4
- package/src/api/types.ts +30 -10
- package/src/{App.test.tsx → app.test.tsx} +1 -1
- package/src/{App.tsx → app.tsx} +10 -1
- package/src/components/chat/ChatSidebar.test.tsx +79 -8
- package/src/components/chat/ChatSidebar.tsx +43 -26
- package/src/components/chat/adapters/chat-message.summary-truncation.test.ts +66 -0
- package/src/components/chat/adapters/file-operation/card.ts +9 -0
- package/src/components/chat/adapters/file-operation/diff.ts +14 -0
- package/src/components/chat/{ChatConversationPanel.test.tsx → chat-conversation-panel.test.tsx} +118 -155
- package/src/components/chat/chat-conversation-panel.tsx +412 -0
- package/src/components/chat/chat-page-runtime.test.ts +1 -1
- package/src/components/chat/chat-page-shell.tsx +1 -1
- package/src/components/chat/{ChatPage.tsx → chat-page.tsx} +1 -1
- package/src/components/chat/chat-session-workspace-file-preview.test.tsx +91 -0
- package/src/components/chat/chat-session-workspace-file-preview.tsx +307 -0
- package/src/components/chat/chat-session-workspace-panel-nav.tsx +197 -0
- package/src/components/chat/chat-session-workspace-panel.tsx +318 -0
- package/src/components/chat/chat-sidebar-session-item.tsx +32 -2
- package/src/components/chat/containers/chat-message-list.container.test.tsx +49 -0
- package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
- package/src/components/chat/managers/chat-session-list.manager.test.ts +94 -31
- package/src/components/chat/managers/chat-session-list.manager.ts +86 -14
- package/src/components/chat/managers/chat-ui.manager.ts +2 -0
- package/src/components/chat/ncp/README.md +1 -1
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +7 -1
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +1 -1
- package/src/components/chat/ncp/{NcpChatPage.tsx → ncp-chat-page.tsx} +7 -7
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +179 -41
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +40 -2
- package/src/components/chat/ncp/ncp-session-adapter.ts +29 -0
- package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +54 -11
- package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +4 -0
- package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +99 -0
- package/src/components/chat/ncp/tests/ncp-chat-thread.manager.test.ts +189 -0
- package/src/components/chat/presenter/chat-presenter-context.tsx +13 -2
- package/src/components/chat/session-header/chat-session-header-actions.test.tsx +26 -0
- package/src/components/chat/session-header/chat-session-header-actions.tsx +19 -1
- package/src/components/chat/stores/chat-session-list.store.ts +25 -54
- package/src/components/chat/stores/chat-thread.store.ts +24 -0
- package/src/components/common/ProviderScopedModelInput.tsx +12 -2
- package/src/components/config/ModelConfig.test.tsx +108 -2
- package/src/components/config/RuntimeConfig.tsx +154 -7
- package/src/components/config/desktop-update-config.test.tsx +85 -0
- package/src/components/config/desktop-update-config.tsx +44 -3
- package/src/components/config/runtime-control-card.test.tsx +255 -0
- package/src/components/config/runtime-control-card.tsx +301 -0
- package/src/components/config/runtime-presence-card.test.tsx +154 -0
- package/src/components/config/runtime-presence-card.tsx +163 -0
- package/src/components/layout/AppLayout.tsx +1 -1
- package/src/components/providers/ThemeProvider.tsx +5 -0
- package/src/desktop/desktop-update.types.ts +25 -0
- package/src/desktop/managers/desktop-presence.manager.ts +91 -0
- package/src/desktop/managers/desktop-update.manager.ts +37 -1
- package/src/desktop/stores/desktop-presence.store.ts +18 -0
- package/src/desktop/stores/desktop-update.store.ts +7 -1
- package/src/hooks/server-path/use-server-path-read.ts +20 -0
- package/src/hooks/use-runtime-control.ts +24 -0
- package/src/lib/chat-message.ts +14 -3
- package/src/lib/desktop-update-labels.utils.ts +28 -2
- package/src/lib/i18n.chat.ts +12 -1
- package/src/lib/i18n.pwa.ts +62 -0
- package/src/lib/i18n.runtime-control.ts +120 -0
- package/src/lib/i18n.ts +4 -6
- package/src/main.tsx +1 -1
- package/src/pwa/components/pwa-install-entry.test.tsx +110 -0
- package/src/pwa/components/pwa-install-entry.tsx +205 -0
- package/src/pwa/managers/pwa-install.manager.test.ts +160 -0
- package/src/pwa/managers/pwa-install.manager.ts +232 -0
- package/src/pwa/managers/pwa-runtime.manager.ts +196 -0
- package/src/pwa/managers/pwa-shell-theme.manager.test.ts +30 -0
- package/src/pwa/managers/pwa-shell-theme.manager.ts +46 -0
- package/src/pwa/pwa-install-banner.storage.ts +55 -0
- package/src/pwa/pwa.types.ts +22 -0
- package/src/pwa/register-pwa.ts +14 -0
- package/src/pwa/stores/pwa.store.ts +17 -0
- package/src/runtime-control/runtime-control.manager.ts +118 -0
- package/src/vite-env.d.ts +9 -0
- package/dist/assets/ChannelsList-D8p4OlM6.js +0 -8
- package/dist/assets/ChatPage-A45t1Rmf.js +0 -58
- package/dist/assets/DocBrowser-B2MpsnU9.js +0 -1
- package/dist/assets/MarketplacePage-BNZ3Jx5d.js +0 -1
- package/dist/assets/McpMarketplacePage-CxPFOgxv.js +0 -40
- package/dist/assets/RemoteAccessPage-DyYVWsyK.js +0 -1
- package/dist/assets/RuntimeConfig-ChdfK4Y_.js +0 -1
- package/dist/assets/chat-session-display-CAlPrnlV.js +0 -1
- package/dist/assets/desktop-update-config-CfoVwf-w.js +0 -1
- package/dist/assets/i18n-C3jb83S6.js +0 -1
- package/dist/assets/index-CE4N7ItL.css +0 -1
- package/dist/assets/index-riX7Sg0_.js +0 -6
- package/dist/assets/loader-circle-BjMg63eu.js +0 -1
- package/dist/assets/plus-CIXME2pD.js +0 -1
- package/dist/assets/search-B_Qr0f6C.js +0 -1
- package/dist/assets/skeleton-CYQJazv6.js +0 -1
- package/dist/assets/x-B8Tho_xC.js +0 -1
- package/src/components/chat/ChatConversationPanel.tsx +0 -256
- package/src/components/chat/chat-child-session-panel.tsx +0 -262
- /package/dist/assets/{config-hints-GSUMvmSo.js → config-hints-BhTmc9P1.js} +0 -0
- /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
|
-
<
|
|
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-
|
|
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
|
+
});
|