@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
@@ -0,0 +1,301 @@
1
+ import { useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import type {
4
+ RuntimeActionCapability,
5
+ RuntimeControlAction,
6
+ RuntimeControlView,
7
+ RuntimeLifecycleState,
8
+ RuntimeServiceState
9
+ } from '@/api/runtime-control.types';
10
+ import { Button } from '@/components/ui/button';
11
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
12
+ import { useRuntimeControl, useRuntimeServiceAction } from '@/hooks/use-runtime-control';
13
+ import { t } from '@/lib/i18n';
14
+ import { runtimeControlManager } from '@/runtime-control/runtime-control.manager';
15
+ import { Loader2, RotateCw, Square, Play } from 'lucide-react';
16
+ import { toast } from 'sonner';
17
+
18
+ type VisibleRuntimeAction = {
19
+ action: RuntimeControlAction;
20
+ capability: RuntimeActionCapability;
21
+ label: string;
22
+ icon: 'play' | 'rotate' | 'square';
23
+ variant?: 'default' | 'secondary' | 'destructive';
24
+ };
25
+
26
+ function resolveLifecycleLabel(lifecycle: RuntimeLifecycleState): string {
27
+ if (lifecycle === 'healthy') {
28
+ return t('runtimeControlHealthy');
29
+ }
30
+ if (lifecycle === 'starting-service') {
31
+ return t('runtimeControlStartingService');
32
+ }
33
+ if (lifecycle === 'restarting-service') {
34
+ return t('runtimeControlRestartingService');
35
+ }
36
+ if (lifecycle === 'stopping-service') {
37
+ return t('runtimeControlStoppingService');
38
+ }
39
+ if (lifecycle === 'restarting-app') {
40
+ return t('runtimeControlRestartingApp');
41
+ }
42
+ if (lifecycle === 'recovering') {
43
+ return t('runtimeControlRecovering');
44
+ }
45
+ if (lifecycle === 'failed') {
46
+ return t('runtimeControlFailed');
47
+ }
48
+ return t('runtimeControlUnavailable');
49
+ }
50
+
51
+ function resolveServiceStateLabel(serviceState: RuntimeServiceState): string {
52
+ if (serviceState === 'running') {
53
+ return t('runtimeControlServiceRunning');
54
+ }
55
+ if (serviceState === 'stopped') {
56
+ return t('runtimeControlServiceStopped');
57
+ }
58
+ if (serviceState === 'starting') {
59
+ return t('runtimeControlServiceStarting');
60
+ }
61
+ if (serviceState === 'stopping') {
62
+ return t('runtimeControlServiceStopping');
63
+ }
64
+ if (serviceState === 'restarting') {
65
+ return t('runtimeControlServiceRestarting');
66
+ }
67
+ return t('runtimeControlServiceUnknown');
68
+ }
69
+
70
+ function resolveEnvironmentLabel(view: RuntimeControlView): string {
71
+ if (view.environment === 'desktop-embedded') {
72
+ return t('runtimeControlEnvironmentDesktop');
73
+ }
74
+ if (view.environment === 'managed-local-service') {
75
+ return t('runtimeControlEnvironmentManagedService');
76
+ }
77
+ if (view.environment === 'self-hosted-web') {
78
+ return t('runtimeControlEnvironmentSelfHosted');
79
+ }
80
+ return t('runtimeControlEnvironmentSharedWeb');
81
+ }
82
+
83
+ function resolveVisibleActions(controlView: RuntimeControlView | undefined): VisibleRuntimeAction[] {
84
+ if (!controlView) {
85
+ return [];
86
+ }
87
+
88
+ const actions: VisibleRuntimeAction[] = [
89
+ {
90
+ action: 'start-service',
91
+ capability: controlView.canStartService,
92
+ label: t('runtimeControlStartService'),
93
+ icon: 'play'
94
+ },
95
+ {
96
+ action: 'restart-service',
97
+ capability: controlView.canRestartService,
98
+ label: t('runtimeControlRestartService'),
99
+ icon: 'rotate'
100
+ },
101
+ {
102
+ action: 'stop-service',
103
+ capability: controlView.canStopService,
104
+ label: t('runtimeControlStopService'),
105
+ icon: 'square',
106
+ variant: 'destructive'
107
+ },
108
+ {
109
+ action: 'restart-app',
110
+ capability: controlView.canRestartApp,
111
+ label: t('runtimeControlRestartApp'),
112
+ icon: 'rotate',
113
+ variant: 'secondary'
114
+ }
115
+ ];
116
+
117
+ return actions.filter((item) => item.capability.available || Boolean(item.capability.reasonIfUnavailable));
118
+ }
119
+
120
+ function resolveActionHelp(action: RuntimeControlAction): string {
121
+ if (action === 'start-service') {
122
+ return t('runtimeControlStartingServiceHelp');
123
+ }
124
+ if (action === 'restart-service') {
125
+ return t('runtimeControlRestartingServiceHelp');
126
+ }
127
+ if (action === 'stop-service') {
128
+ return t('runtimeControlStoppingServiceHelp');
129
+ }
130
+ return t('runtimeControlRestartingAppHelp');
131
+ }
132
+
133
+ function RuntimeActionIcon(props: { icon: VisibleRuntimeAction['icon']; busy: boolean }) {
134
+ const { busy, icon } = props;
135
+ if (busy) {
136
+ return <Loader2 className="mr-2 h-4 w-4 animate-spin" />;
137
+ }
138
+ if (icon === 'play') {
139
+ return <Play className="mr-2 h-4 w-4" />;
140
+ }
141
+ if (icon === 'square') {
142
+ return <Square className="mr-2 h-4 w-4" />;
143
+ }
144
+ return <RotateCw className="mr-2 h-4 w-4" />;
145
+ }
146
+
147
+ export function RuntimeControlCard() {
148
+ const queryClient = useQueryClient();
149
+ const runtimeControlQuery = useRuntimeControl();
150
+ const serviceActionMutation = useRuntimeServiceAction();
151
+ const [localLifecycle, setLocalLifecycle] = useState<RuntimeLifecycleState | null>(null);
152
+ const [localServiceState, setLocalServiceState] = useState<RuntimeServiceState | null>(null);
153
+ const [localMessage, setLocalMessage] = useState<string | null>(null);
154
+ const [busyAction, setBusyAction] = useState<RuntimeControlAction | null>(null);
155
+
156
+ const controlView = runtimeControlQuery.data;
157
+ const displayedLifecycle = localLifecycle ?? controlView?.lifecycle ?? 'healthy';
158
+ const displayedServiceState = localServiceState ?? controlView?.serviceState ?? 'unknown';
159
+ const displayedMessage = localMessage ?? controlView?.message ?? t('runtimeControlDescription');
160
+ const busy = serviceActionMutation.isPending || busyAction !== null || displayedLifecycle === 'recovering';
161
+ const visibleActions = resolveVisibleActions(controlView);
162
+
163
+ const resetLocalState = () => {
164
+ setLocalLifecycle(null);
165
+ setLocalServiceState(null);
166
+ setLocalMessage(null);
167
+ setBusyAction(null);
168
+ };
169
+
170
+ const handleServiceAction = async (action: Extract<RuntimeControlAction, 'start-service' | 'restart-service' | 'stop-service'>) => {
171
+ const capability = action === 'start-service'
172
+ ? controlView?.canStartService
173
+ : action === 'stop-service'
174
+ ? controlView?.canStopService
175
+ : controlView?.canRestartService;
176
+
177
+ if (!capability?.available) {
178
+ toast.error(capability?.reasonIfUnavailable ?? t('runtimeControlLoadFailed'));
179
+ return;
180
+ }
181
+ if (action === 'stop-service' && capability.requiresConfirmation && !window.confirm(t('runtimeControlStopServiceConfirm'))) {
182
+ return;
183
+ }
184
+
185
+ setBusyAction(action);
186
+ setLocalLifecycle(action === 'start-service' ? 'starting-service' : action === 'stop-service' ? 'stopping-service' : 'restarting-service');
187
+ setLocalServiceState(action === 'start-service' ? 'starting' : action === 'stop-service' ? 'stopping' : 'restarting');
188
+ setLocalMessage(resolveActionHelp(action));
189
+
190
+ try {
191
+ const result = await serviceActionMutation.mutateAsync(action);
192
+ toast.success(result.message);
193
+ if (action === 'stop-service') {
194
+ return;
195
+ }
196
+ setLocalLifecycle('recovering');
197
+ setLocalMessage(t('runtimeControlRecoveringHelp'));
198
+ const recoveredView = await runtimeControlManager.waitForRecovery();
199
+ queryClient.setQueryData(['runtime-control'], recoveredView);
200
+ await queryClient.invalidateQueries({ queryKey: ['runtime-control'] });
201
+ resetLocalState();
202
+ toast.success(t('runtimeControlRecovered'));
203
+ } catch (error) {
204
+ const message = error instanceof Error ? error.message : t('runtimeControlActionFailed');
205
+ setLocalLifecycle('failed');
206
+ setLocalServiceState(action === 'stop-service' ? 'running' : 'unknown');
207
+ setLocalMessage(message);
208
+ setBusyAction(null);
209
+ toast.error(`${t('runtimeControlActionFailed')}: ${message}`);
210
+ }
211
+ };
212
+
213
+ const handleRestartApp = async () => {
214
+ if (!controlView?.canRestartApp.available) {
215
+ toast.error(controlView?.canRestartApp.reasonIfUnavailable ?? t('runtimeRestartAppUnavailable'));
216
+ return;
217
+ }
218
+ if (!window.confirm(t('runtimeControlRestartAppConfirm'))) {
219
+ return;
220
+ }
221
+
222
+ setBusyAction('restart-app');
223
+ setLocalLifecycle('restarting-app');
224
+ setLocalMessage(t('runtimeControlRestartingAppHelp'));
225
+
226
+ try {
227
+ const result = await runtimeControlManager.restartApp();
228
+ toast.success(result.message);
229
+ } catch (error) {
230
+ const message = error instanceof Error ? error.message : t('runtimeControlActionFailed');
231
+ setLocalLifecycle('failed');
232
+ setLocalMessage(message);
233
+ setBusyAction(null);
234
+ toast.error(`${t('runtimeControlActionFailed')}: ${message}`);
235
+ }
236
+ };
237
+
238
+ return (
239
+ <Card>
240
+ <CardHeader>
241
+ <CardTitle>{t('runtimeControlTitle')}</CardTitle>
242
+ <CardDescription>{t('runtimeControlDescription')}</CardDescription>
243
+ </CardHeader>
244
+ <CardContent className="space-y-4">
245
+ <div className="rounded-xl border border-gray-200 bg-gray-50 p-4 space-y-3">
246
+ <div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
247
+ <div className="text-sm font-medium text-gray-900">{resolveServiceStateLabel(displayedServiceState)}</div>
248
+ <div className="text-xs text-gray-500">
249
+ {controlView ? resolveEnvironmentLabel(controlView) : t('runtimeControlLoading')}
250
+ </div>
251
+ </div>
252
+ <p className="text-sm text-gray-600">{displayedMessage}</p>
253
+ <div className="text-xs text-gray-500">{resolveLifecycleLabel(displayedLifecycle)}</div>
254
+ {controlView?.managementHint ? (
255
+ <p className="text-xs text-gray-500">{controlView.managementHint}</p>
256
+ ) : null}
257
+ {runtimeControlQuery.isError && !busy ? (
258
+ <p className="text-sm text-amber-700">
259
+ {runtimeControlQuery.error instanceof Error ? runtimeControlQuery.error.message : t('runtimeControlLoadFailed')}
260
+ </p>
261
+ ) : null}
262
+ </div>
263
+
264
+ <div className="flex flex-col gap-3 md:flex-row md:flex-wrap">
265
+ {visibleActions.map((item) => {
266
+ const isBusyAction = busyAction === item.action;
267
+ const disabled = !item.capability.available || busy;
268
+ const handleClick = () => {
269
+ if (item.action === 'restart-app') {
270
+ void handleRestartApp();
271
+ return;
272
+ }
273
+ void handleServiceAction(item.action);
274
+ };
275
+
276
+ return (
277
+ <Button
278
+ key={item.action}
279
+ type="button"
280
+ variant={item.variant ?? 'default'}
281
+ onClick={handleClick}
282
+ disabled={disabled}
283
+ >
284
+ <RuntimeActionIcon icon={item.icon} busy={isBusyAction} />
285
+ {item.label}
286
+ </Button>
287
+ );
288
+ })}
289
+ </div>
290
+
291
+ {visibleActions
292
+ .filter((item) => !item.capability.available && item.capability.reasonIfUnavailable)
293
+ .map((item) => (
294
+ <p key={`${item.action}-reason`} className="text-xs text-gray-500">
295
+ {item.capability.reasonIfUnavailable}
296
+ </p>
297
+ ))}
298
+ </CardContent>
299
+ </Card>
300
+ );
301
+ }
@@ -0,0 +1,154 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { RuntimePresenceCard } from '@/components/config/runtime-presence-card';
5
+ import { useDesktopPresenceStore } from '@/desktop/stores/desktop-presence.store';
6
+ import { setLanguage } from '@/lib/i18n';
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ useRuntimeControl: vi.fn(),
10
+ toastSuccess: vi.fn(),
11
+ toastError: vi.fn()
12
+ }));
13
+
14
+ vi.mock('@/hooks/use-runtime-control', () => ({
15
+ useRuntimeControl: (...args: unknown[]) => mocks.useRuntimeControl(...args)
16
+ }));
17
+
18
+ vi.mock('sonner', () => ({
19
+ toast: {
20
+ success: (...args: unknown[]) => mocks.toastSuccess(...args),
21
+ error: (...args: unknown[]) => mocks.toastError(...args)
22
+ }
23
+ }));
24
+
25
+ describe('RuntimePresenceCard', () => {
26
+ beforeEach(() => {
27
+ setLanguage('zh');
28
+ vi.clearAllMocks();
29
+ useDesktopPresenceStore.setState({
30
+ supported: false,
31
+ initialized: false,
32
+ busyAction: null,
33
+ snapshot: null
34
+ });
35
+ mocks.useRuntimeControl.mockReturnValue({
36
+ data: {
37
+ environment: 'managed-local-service',
38
+ lifecycle: 'healthy',
39
+ serviceState: 'running',
40
+ canStartService: {
41
+ available: false,
42
+ requiresConfirmation: false,
43
+ impact: 'brief-ui-disconnect',
44
+ reasonIfUnavailable: 'running local service'
45
+ },
46
+ message: 'runtime healthy',
47
+ canRestartService: {
48
+ available: true,
49
+ requiresConfirmation: false,
50
+ impact: 'brief-ui-disconnect'
51
+ },
52
+ canStopService: {
53
+ available: true,
54
+ requiresConfirmation: true,
55
+ impact: 'brief-ui-disconnect'
56
+ },
57
+ canRestartApp: {
58
+ available: false,
59
+ requiresConfirmation: true,
60
+ impact: 'full-app-relaunch',
61
+ reasonIfUnavailable: 'desktop only'
62
+ },
63
+ managementHint: 'managed service hint'
64
+ },
65
+ isError: false,
66
+ error: null
67
+ });
68
+ window.nextclawDesktop = undefined;
69
+ });
70
+
71
+ it('explains that closing the browser does not stop the managed local service', () => {
72
+ render(<RuntimePresenceCard />);
73
+
74
+ expect(screen.getByText('浏览器只是本地服务控制面')).toBeTruthy();
75
+ expect(screen.getByText('关闭浏览器标签页不会停止本地 NextClaw 服务。服务生命周期由本地受管服务负责,而不是由页面生命周期决定。')).toBeTruthy();
76
+ });
77
+
78
+ it('loads desktop presence settings and updates close-to-background preference', async () => {
79
+ const user = userEvent.setup();
80
+ const getPresenceState = vi.fn().mockResolvedValue({
81
+ closeToBackground: true,
82
+ launchAtLogin: false,
83
+ supportsLaunchAtLogin: true,
84
+ launchAtLoginReason: null
85
+ });
86
+ const updatePresencePreferences = vi.fn().mockResolvedValue({
87
+ closeToBackground: false,
88
+ launchAtLogin: false,
89
+ supportsLaunchAtLogin: true,
90
+ launchAtLoginReason: null
91
+ });
92
+
93
+ window.nextclawDesktop = {
94
+ platform: 'darwin',
95
+ version: '32.2.1',
96
+ getUpdateState: vi.fn(),
97
+ checkForUpdates: vi.fn(),
98
+ downloadUpdate: vi.fn(),
99
+ applyDownloadedUpdate: vi.fn(),
100
+ updatePreferences: vi.fn(),
101
+ updateChannel: vi.fn(),
102
+ restartService: vi.fn(),
103
+ restartApp: vi.fn(),
104
+ getPresenceState,
105
+ updatePresencePreferences,
106
+ onUpdateStateChanged: vi.fn(() => () => {})
107
+ };
108
+
109
+ mocks.useRuntimeControl.mockReturnValue({
110
+ data: {
111
+ environment: 'desktop-embedded',
112
+ lifecycle: 'healthy',
113
+ serviceState: 'running',
114
+ canStartService: {
115
+ available: false,
116
+ requiresConfirmation: false,
117
+ impact: 'none'
118
+ },
119
+ message: 'runtime healthy',
120
+ canRestartService: {
121
+ available: true,
122
+ requiresConfirmation: false,
123
+ impact: 'brief-ui-disconnect'
124
+ },
125
+ canStopService: {
126
+ available: false,
127
+ requiresConfirmation: true,
128
+ impact: 'brief-ui-disconnect'
129
+ },
130
+ canRestartApp: {
131
+ available: true,
132
+ requiresConfirmation: true,
133
+ impact: 'full-app-relaunch'
134
+ },
135
+ managementHint: 'desktop hint'
136
+ },
137
+ isError: false,
138
+ error: null
139
+ });
140
+
141
+ render(<RuntimePresenceCard />);
142
+
143
+ await waitFor(() => {
144
+ expect(getPresenceState).toHaveBeenCalledTimes(1);
145
+ expect(screen.getByText('关闭窗口时隐藏到后台')).toBeTruthy();
146
+ });
147
+
148
+ await user.click(screen.getByRole('switch', { name: '关闭窗口时继续在后台运行' }));
149
+
150
+ await waitFor(() => {
151
+ expect(updatePresencePreferences).toHaveBeenCalledWith({ closeToBackground: false });
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,163 @@
1
+ import { useEffect } from 'react';
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
3
+ import { Label } from '@/components/ui/label';
4
+ import { Switch } from '@/components/ui/switch';
5
+ import { desktopPresenceManager } from '@/desktop/managers/desktop-presence.manager';
6
+ import { useDesktopPresenceStore } from '@/desktop/stores/desktop-presence.store';
7
+ import { useRuntimeControl } from '@/hooks/use-runtime-control';
8
+ import { t } from '@/lib/i18n';
9
+
10
+ function PresenceHint(props: { title: string; description: string }) {
11
+ const { description, title } = props;
12
+ return (
13
+ <div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
14
+ <p className="text-sm font-medium text-gray-900">{title}</p>
15
+ <p className="mt-2 text-sm leading-6 text-gray-600">{description}</p>
16
+ </div>
17
+ );
18
+ }
19
+
20
+ export function RuntimePresenceCard() {
21
+ const runtimeControlQuery = useRuntimeControl();
22
+ const environment = runtimeControlQuery.data?.environment;
23
+ const supported = useDesktopPresenceStore((state) => state.supported);
24
+ const initialized = useDesktopPresenceStore((state) => state.initialized);
25
+ const busyAction = useDesktopPresenceStore((state) => state.busyAction);
26
+ const snapshot = useDesktopPresenceStore((state) => state.snapshot);
27
+
28
+ useEffect(() => {
29
+ if (environment === 'desktop-embedded') {
30
+ void desktopPresenceManager.start();
31
+ return;
32
+ }
33
+ desktopPresenceManager.markUnsupported();
34
+ }, [environment]);
35
+
36
+ if (environment === 'desktop-embedded') {
37
+ return (
38
+ <Card>
39
+ <CardHeader>
40
+ <CardTitle>{t('runtimePresenceTitle')}</CardTitle>
41
+ <CardDescription>{t('runtimePresenceDescription')}</CardDescription>
42
+ </CardHeader>
43
+ <CardContent className="space-y-4">
44
+ <div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
45
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">
46
+ {t('runtimePresenceBehaviorLabel')}
47
+ </p>
48
+ <p className="mt-2 text-sm font-medium text-gray-900">
49
+ {snapshot?.closeToBackground ? t('runtimePresenceBehaviorBackground') : t('runtimePresenceBehaviorQuit')}
50
+ </p>
51
+ </div>
52
+
53
+ {!initialized || (supported && !snapshot) ? (
54
+ <p className="text-sm text-gray-500">{t('runtimePresenceLoading')}</p>
55
+ ) : null}
56
+
57
+ {snapshot ? (
58
+ <div className="space-y-4">
59
+ <div className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 p-4">
60
+ <div className="space-y-2">
61
+ <Label htmlFor="runtime-presence-close-background">{t('runtimePresenceCloseToBackground')}</Label>
62
+ <p className="text-sm text-gray-500">{t('runtimePresenceCloseToBackgroundHelp')}</p>
63
+ </div>
64
+ <Switch
65
+ id="runtime-presence-close-background"
66
+ aria-label={t('runtimePresenceCloseToBackground')}
67
+ checked={snapshot.closeToBackground}
68
+ disabled={busyAction === 'saving-preferences'}
69
+ onCheckedChange={(checked) => {
70
+ void desktopPresenceManager.updatePreferences({ closeToBackground: checked });
71
+ }}
72
+ />
73
+ </div>
74
+
75
+ <div className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 p-4">
76
+ <div className="space-y-2">
77
+ <Label htmlFor="runtime-presence-launch-login">{t('runtimePresenceLaunchAtLogin')}</Label>
78
+ <p className="text-sm text-gray-500">
79
+ {snapshot.supportsLaunchAtLogin
80
+ ? t('runtimePresenceLaunchAtLoginHelp')
81
+ : snapshot.launchAtLoginReason ?? t('runtimePresenceLaunchAtLoginUnavailable')}
82
+ </p>
83
+ </div>
84
+ <Switch
85
+ id="runtime-presence-launch-login"
86
+ aria-label={t('runtimePresenceLaunchAtLogin')}
87
+ checked={snapshot.launchAtLogin}
88
+ disabled={!snapshot.supportsLaunchAtLogin || busyAction === 'saving-preferences'}
89
+ onCheckedChange={(checked) => {
90
+ void desktopPresenceManager.updatePreferences({ launchAtLogin: checked });
91
+ }}
92
+ />
93
+ </div>
94
+ </div>
95
+ ) : null}
96
+ </CardContent>
97
+ </Card>
98
+ );
99
+ }
100
+
101
+ if (environment === 'managed-local-service') {
102
+ return (
103
+ <Card>
104
+ <CardHeader>
105
+ <CardTitle>{t('runtimePresenceTitle')}</CardTitle>
106
+ <CardDescription>{t('runtimePresenceDescription')}</CardDescription>
107
+ </CardHeader>
108
+ <CardContent>
109
+ <PresenceHint
110
+ title={t('runtimePresenceManagedLocalTitle')}
111
+ description={t('runtimePresenceManagedLocalDescription')}
112
+ />
113
+ </CardContent>
114
+ </Card>
115
+ );
116
+ }
117
+
118
+ if (environment === 'self-hosted-web') {
119
+ return (
120
+ <Card>
121
+ <CardHeader>
122
+ <CardTitle>{t('runtimePresenceTitle')}</CardTitle>
123
+ <CardDescription>{t('runtimePresenceDescription')}</CardDescription>
124
+ </CardHeader>
125
+ <CardContent>
126
+ <PresenceHint
127
+ title={t('runtimePresenceSelfHostedTitle')}
128
+ description={t('runtimePresenceSelfHostedDescription')}
129
+ />
130
+ </CardContent>
131
+ </Card>
132
+ );
133
+ }
134
+
135
+ if (environment === 'shared-web') {
136
+ return (
137
+ <Card>
138
+ <CardHeader>
139
+ <CardTitle>{t('runtimePresenceTitle')}</CardTitle>
140
+ <CardDescription>{t('runtimePresenceDescription')}</CardDescription>
141
+ </CardHeader>
142
+ <CardContent>
143
+ <PresenceHint
144
+ title={t('runtimePresenceSharedTitle')}
145
+ description={t('runtimePresenceSharedDescription')}
146
+ />
147
+ </CardContent>
148
+ </Card>
149
+ );
150
+ }
151
+
152
+ return (
153
+ <Card>
154
+ <CardHeader>
155
+ <CardTitle>{t('runtimePresenceTitle')}</CardTitle>
156
+ <CardDescription>{t('runtimePresenceDescription')}</CardDescription>
157
+ </CardHeader>
158
+ <CardContent>
159
+ <p className="text-sm text-gray-500">{t('runtimePresenceLoading')}</p>
160
+ </CardContent>
161
+ </Card>
162
+ );
163
+ }
@@ -54,7 +54,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
54
54
  ) : (
55
55
  <main
56
56
  className={cn(
57
- 'flex-1 custom-scrollbar p-8',
57
+ 'flex-1 custom-scrollbar p-8 pb-24',
58
58
  lockPageScroll ? 'overflow-auto xl:overflow-hidden' : 'overflow-auto'
59
59
  )}
60
60
  >
@@ -14,6 +14,7 @@ import {
14
14
  subscribeThemeChange,
15
15
  type UiTheme,
16
16
  } from '@/lib/theme';
17
+ import { pwaShellThemeManager } from '@/pwa/managers/pwa-shell-theme.manager';
17
18
 
18
19
  type ThemeContextValue = {
19
20
  theme: UiTheme;
@@ -25,6 +26,10 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
25
26
  export function ThemeProvider({ children }: { children: ReactNode }) {
26
27
  const [theme, setThemeState] = useState<UiTheme>(() => initializeTheme());
27
28
 
29
+ useEffect(() => {
30
+ pwaShellThemeManager.syncTheme(theme);
31
+ }, [theme]);
32
+
28
33
  useEffect(() => {
29
34
  const unsubscribe = subscribeThemeChange((nextTheme) => {
30
35
  setThemeState(nextTheme);