@nextclaw/ui 0.12.19 → 0.12.20-beta.0

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 (152) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/assets/api-C412zuay.js +15 -0
  3. package/dist/assets/app-manager-provider-Cm-KiZZG.js +1 -0
  4. package/dist/assets/app-navigation.config-BORqHkbN.js +1 -0
  5. package/dist/assets/{book-open-CVEuA0y5.js → book-open-DgLqYpNY.js} +1 -1
  6. package/dist/assets/{channels-list-page-BqhqaBf1.js → channels-list-page-sISO_4Yj.js} +2 -2
  7. package/dist/assets/{chat-D4KecKjB.js → chat-ChCu7LQD.js} +13 -12
  8. package/dist/assets/chat-page-BCaNZJGT.js +1 -0
  9. package/dist/assets/{chunk-JZWAC4HX-24FLdHl7.js → chunk-JZWAC4HX-DvbcIVPf.js} +1 -1
  10. package/dist/assets/{config-split-page-BGjVACdO.js → config-split-page-BMRGuCJQ.js} +1 -1
  11. package/dist/assets/{createLucideIcon-PPrXCGK8.js → createLucideIcon-BZkY6emz.js} +1 -1
  12. package/dist/assets/desktop-update-config-BfJ5iSeY.js +1 -0
  13. package/dist/assets/{dialog-CTCX7oLf.js → dialog-B-CXiFPZ.js} +1 -1
  14. package/dist/assets/{dist-FL5e8mMi.js → dist-DYVfg3q5.js} +1 -1
  15. package/dist/assets/{doc-browser-C02neCIE.js → doc-browser-BUlCkZo2.js} +1 -1
  16. package/dist/assets/doc-browser-CzCV73NJ.js +1 -0
  17. package/dist/assets/doc-browser-Doh2541x.js +1 -0
  18. package/dist/assets/{doc-browser-context-C-WPOji4.js → doc-browser-context-DfLHAWbG.js} +1 -1
  19. package/dist/assets/{es2015-BNy4R8AC.js → es2015-BXroVnPi.js} +1 -1
  20. package/dist/assets/{external-link-BNtqJE01.js → external-link-Sw3ah_JD.js} +1 -1
  21. package/dist/assets/{folder-QyJHVUNz.js → folder-D7-VTnkz.js} +1 -1
  22. package/dist/assets/{hash-BGYUE-zr.js → hash-zajSTDXZ.js} +1 -1
  23. package/dist/assets/i18n-C5Mibli1.js +1 -0
  24. package/dist/assets/index-CUmk8xFK.css +1 -0
  25. package/dist/assets/index-CqPDhosM.js +2 -0
  26. package/dist/assets/{key-round-DenCfA2w.js → key-round-CnI1mc9F.js} +1 -1
  27. package/dist/assets/loader-circle-B5i8oMMY.js +1 -0
  28. package/dist/assets/{logo-badge-CKAxvQFc.js → logo-badge-BQgKnVtz.js} +1 -1
  29. package/dist/assets/{logos-CqXnaJIm.js → logos-CqVm0q0W.js} +1 -1
  30. package/dist/assets/marketplace-page-C8uaWkfd.js +1 -0
  31. package/dist/assets/{marketplace-page-XnDa2ulT.js → marketplace-page-C9oZ01rM.js} +2 -2
  32. package/dist/assets/mcp-marketplace-page-DuEixgSs.js +40 -0
  33. package/dist/assets/mcp-marketplace-page-rNqr6ZpD.js +1 -0
  34. package/dist/assets/message-square-D6Z4NwpG.js +1 -0
  35. package/dist/assets/{model-config-ByeL6Toe.js → model-config-mfhqEZBG.js} +1 -1
  36. package/dist/assets/{notice-card-D00-02yg.js → notice-card-CozHB03G.js} +1 -1
  37. package/dist/assets/play-D8WJLnJe.js +1 -0
  38. package/dist/assets/plus-Di0KAkiO.js +1 -0
  39. package/dist/assets/{popover-AmJkxio3.js → popover-CPUPma-w.js} +1 -1
  40. package/dist/assets/{provider-scoped-model-input-CfFJsJp-.js → provider-scoped-model-input-CL9sti2I.js} +1 -1
  41. package/dist/assets/{providers-list-HMQzW2WV.js → providers-list-HPmL2akJ.js} +1 -1
  42. package/dist/assets/{refresh-ccw-B-dhb3yS.js → refresh-ccw-Bii4w8aB.js} +1 -1
  43. package/dist/assets/refresh-cw-BxojR62w.js +1 -0
  44. package/dist/assets/remote-oDlAdgVA.js +1 -0
  45. package/dist/assets/{rotate-cw-BWqAG3Fv.js → rotate-cw-1Xqa7LZ8.js} +1 -1
  46. package/dist/assets/runtime-config-page-BCshTAAE.js +1 -0
  47. package/dist/assets/{save-DpdkGieJ.js → save--BVI5wZX.js} +1 -1
  48. package/dist/assets/search-config-Bcnk9VlL.js +1 -0
  49. package/dist/assets/{search-CQUdr7j_.js → search-vChioOoe.js} +1 -1
  50. package/dist/assets/{secrets-config-YCsGd1am.js → secrets-config-Dde-5Y1w.js} +2 -2
  51. package/dist/assets/{select-DVUtSFHZ.js → select-BELPuXLW.js} +1 -1
  52. package/dist/assets/{sessions-config-page-BKN-XdKr.js → sessions-config-page-CG49_0Z6.js} +2 -2
  53. package/dist/assets/{setting-row-Cb5-lFs-.js → setting-row-D5DtT6Ny.js} +1 -1
  54. package/dist/assets/{settings-DgtZZlnF.js → settings-CiRChctQ.js} +1 -1
  55. package/dist/assets/skeleton-CFQRIUzt.js +1 -0
  56. package/dist/assets/{sparkles-DNSCyDhL.js → sparkles-D1ZKWdm4.js} +1 -1
  57. package/dist/assets/{status-dot-X_j51OfA.js → status-dot-Dv_hiUVa.js} +1 -1
  58. package/dist/assets/{tabs-custom-CcWmekaF.js → tabs-custom-CsACkVji.js} +1 -1
  59. package/dist/assets/{tag-chip-fdbK2wE6.js → tag-chip-D9BWWgYg.js} +1 -1
  60. package/dist/assets/theme-provider-DeBrTglS.js +1 -0
  61. package/dist/assets/{tooltip-BkZCQcKw.js → tooltip-CI0rpNee.js} +1 -1
  62. package/dist/assets/{trash-2-CqciSCsg.js → trash-2-rY9ZteZX.js} +1 -1
  63. package/dist/assets/use-config-CrWZ_TSF.js +1 -0
  64. package/dist/assets/{use-confirm-dialog-DSrb9205.js → use-confirm-dialog-hbynwWf2.js} +1 -1
  65. package/dist/assets/{use-infinite-scroll-loader-DmowtyTI.js → use-infinite-scroll-loader-Cw5qQr3-.js} +1 -1
  66. package/dist/assets/{use-viewport-layout-CaALCA51.js → use-viewport-layout-CWHVDC6z.js} +1 -1
  67. package/dist/assets/x-DpTzXQcX.js +1 -0
  68. package/dist/index.html +40 -39
  69. package/package.json +7 -6
  70. package/src/app/index.tsx +7 -1
  71. package/src/features/channels/components/config/weixin-channel-auth-section.tsx +1 -1
  72. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +1 -0
  73. package/src/features/chat/components/conversation/chat-input-bar.container.tsx +9 -4
  74. package/src/features/chat/components/conversation/chat-message-list.container.test.tsx +64 -6
  75. package/src/features/chat/components/conversation/chat-message-list.container.tsx +185 -17
  76. package/src/features/chat/components/session/session-context-icon.tsx +1 -4
  77. package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +3 -1
  78. package/src/features/chat/hooks/use-ncp-session-conversation.test.tsx +74 -2
  79. package/src/features/chat/hooks/use-ncp-session-conversation.ts +32 -10
  80. package/src/features/chat/hooks/use-selected-session-context-window-indicator.ts +20 -0
  81. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +25 -0
  82. package/src/features/chat/managers/ncp-chat-input.manager.ts +5 -1
  83. package/src/features/chat/pages/ncp-chat-page.tsx +15 -11
  84. package/src/features/chat/stores/chat-thread.store.ts +8 -2
  85. package/src/features/chat/utils/chat-context-window-indicator.utils.ts +50 -0
  86. package/src/features/chat/utils/chat-runtime.utils.ts +1 -1
  87. package/src/features/chat/utils/ncp-chat-runtime-availability.utils.test.ts +165 -0
  88. package/src/features/chat/utils/ncp-chat-runtime-availability.utils.ts +50 -0
  89. package/src/features/chat/utils/ncp-session-adapter.utils.test.ts +27 -0
  90. package/src/features/chat/utils/ncp-session-adapter.utils.ts +6 -4
  91. package/src/features/chat/utils/ncp-session-context-metadata.utils.ts +121 -0
  92. package/src/features/chat/utils/session-context.utils.ts +1 -2
  93. package/src/features/system-status/components/config/runtime-config-editor.tsx +6 -0
  94. package/src/features/system-status/components/config/runtime-settings-card.tsx +12 -0
  95. package/src/features/system-status/components/desktop-update-config.test.tsx +17 -7
  96. package/src/features/system-status/components/desktop-update-config.tsx +75 -30
  97. package/src/features/system-status/hooks/use-system-status.ts +0 -11
  98. package/src/features/system-status/index.ts +4 -1
  99. package/src/features/system-status/managers/runtime-update.manager.ts +330 -0
  100. package/src/features/system-status/managers/system-status.manager.test.ts +0 -25
  101. package/src/features/system-status/managers/system-status.manager.ts +1 -30
  102. package/src/features/system-status/stores/runtime-update.store.ts +24 -0
  103. package/src/features/system-status/types/system-status.types.ts +0 -2
  104. package/src/features/system-status/utils/runtime-config-agent.utils.ts +6 -1
  105. package/src/features/system-status/utils/system-status.utils.test.ts +1 -85
  106. package/src/features/system-status/utils/system-status.utils.ts +1 -23
  107. package/src/platforms/desktop/managers/desktop-update.manager.ts +6 -0
  108. package/src/platforms/desktop/types/desktop-update.types.ts +21 -19
  109. package/src/shared/components/common/brand-header.test.tsx +142 -0
  110. package/src/shared/components/common/brand-header.tsx +93 -0
  111. package/src/shared/components/cron-config.tsx +1 -1
  112. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +1 -1
  113. package/src/shared/components/doc-browser/doc-browser.tsx +1 -1
  114. package/src/shared/components/search-config.tsx +3 -3
  115. package/src/shared/lib/api/README.md +3 -0
  116. package/src/shared/lib/api/index.ts +2 -0
  117. package/src/shared/lib/api/ncp-attachments.ts +2 -2
  118. package/src/shared/lib/api/ncp-session.types.ts +92 -0
  119. package/src/shared/lib/api/runtime-update.service.ts +50 -0
  120. package/src/shared/lib/api/types.ts +9 -74
  121. package/src/shared/lib/i18n/{chat.ts → chat-labels.utils.ts} +13 -1
  122. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +65 -0
  123. package/src/shared/lib/i18n/index.ts +4 -5
  124. package/src/shared/lib/i18n/runtime/i18n-language-owner.ts +5 -5
  125. package/src/shared/lib/transport/remote-transport.service.ts +1 -1
  126. package/src/shared/lib/ui-document-title/index.ts +1 -1
  127. package/tsconfig.json +1 -0
  128. package/dist/assets/api-BurjmW4A.js +0 -15
  129. package/dist/assets/app-manager-provider-DhxUmyTv.js +0 -1
  130. package/dist/assets/app-navigation.config-Bpd16Pem.js +0 -1
  131. package/dist/assets/chat-page-Cc7n80lW.js +0 -1
  132. package/dist/assets/desktop-update-config-fMLlSStv.js +0 -1
  133. package/dist/assets/doc-browser-COj7x090.js +0 -1
  134. package/dist/assets/doc-browser-fyn7eDTp.js +0 -1
  135. package/dist/assets/i18n-CM4y8Mw9.js +0 -1
  136. package/dist/assets/index-CtVSzMPM.js +0 -2
  137. package/dist/assets/index-N3hjuljD.css +0 -1
  138. package/dist/assets/loader-circle-R23uEPkM.js +0 -1
  139. package/dist/assets/marketplace-page-mF-M5mku.js +0 -1
  140. package/dist/assets/mcp-marketplace-page-BArKWcRZ.js +0 -40
  141. package/dist/assets/mcp-marketplace-page-DBUcIIHJ.js +0 -1
  142. package/dist/assets/message-square-Dm34zD6k.js +0 -1
  143. package/dist/assets/play-ul4L6MWm.js +0 -1
  144. package/dist/assets/plus-D14303DH.js +0 -1
  145. package/dist/assets/remote-B4ELSd3u.js +0 -1
  146. package/dist/assets/runtime-config-page-N4FP6H0M.js +0 -1
  147. package/dist/assets/search-config-B62TY-z2.js +0 -1
  148. package/dist/assets/skeleton-BCPi52jT.js +0 -1
  149. package/dist/assets/theme-provider-WTWq_jYq.js +0 -1
  150. package/dist/assets/use-config-CyvhbRhf.js +0 -1
  151. package/dist/assets/x-tYcSDsrY.js +0 -1
  152. /package/dist/assets/{config-hints-CPNzbMEp.js → config-hints-MogHYQ8G.js} +0 -0
@@ -1,4 +1,6 @@
1
+ import type { UpdateSnapshot } from '@nextclaw/kernel';
1
2
  import { useEffect } from 'react';
3
+ import { runtimeUpdateManager, useRuntimeUpdateStore } from '@/features/system-status';
2
4
  import { Button } from '@/shared/components/ui/button';
3
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/components/ui/card';
4
6
  import { Label } from '@/shared/components/ui/label';
@@ -7,11 +9,6 @@ import { Switch } from '@/shared/components/ui/switch';
7
9
  import { PageHeader, PageLayout } from '@/app/components/layout/page-layout';
8
10
  import { formatDateTime, t } from '@/shared/lib/i18n';
9
11
  import { cn } from '@/shared/lib/utils';
10
- import {
11
- desktopUpdateManager,
12
- type DesktopReleaseChannel,
13
- useDesktopUpdateStore,
14
- } from '@/platforms/desktop';
15
12
  import { Download, ExternalLink, RefreshCw, RotateCw } from 'lucide-react';
16
13
 
17
14
  const STATUS_LABEL_KEYS: Record<string, string> = {
@@ -20,6 +17,7 @@ const STATUS_LABEL_KEYS: Record<string, string> = {
20
17
  downloading: 'desktopUpdatesStatusDownloading',
21
18
  downloaded: 'desktopUpdatesStatusDownloaded',
22
19
  'up-to-date': 'desktopUpdatesStatusUpToDate',
20
+ blocked: 'desktopUpdatesStatusBlocked',
23
21
  failed: 'desktopUpdatesStatusFailed',
24
22
  };
25
23
 
@@ -31,6 +29,28 @@ function OverviewStat({ label, value }: { label: string; value: string }) {
31
29
  return <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4"><p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{label}</p><p className="mt-2 text-base font-semibold text-gray-900">{value}</p></div>;
32
30
  }
33
31
 
32
+ function DownloadProgress({ snapshot }: { snapshot: UpdateSnapshot }) {
33
+ if (snapshot.status !== 'downloading') {
34
+ return null;
35
+ }
36
+ const percent = snapshot.progress?.percent;
37
+ const progressLabel = percent === null || percent === undefined
38
+ ? t('desktopUpdatesDownloadProgressUnknown')
39
+ : t('desktopUpdatesDownloadProgressPercent').replace('{percent}', String(percent));
40
+ const byteLabel = formatDownloadBytes(snapshot.progress?.downloadedBytes ?? 0, snapshot.progress?.totalBytes ?? null);
41
+ return (
42
+ <div className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4">
43
+ <div className="flex items-center justify-between gap-4">
44
+ <p className="text-sm font-semibold text-amber-800">{progressLabel}</p>
45
+ <p className="text-xs font-medium text-amber-700">{byteLabel}</p>
46
+ </div>
47
+ <div className="mt-3 h-2 overflow-hidden rounded-full bg-amber-100">
48
+ <div className="h-full rounded-full bg-amber-500 transition-[width]" style={{ width: `${percent ?? 0}%` }} />
49
+ </div>
50
+ </div>
51
+ );
52
+ }
53
+
34
54
  function PreferenceToggle({
35
55
  label,
36
56
  help,
@@ -61,7 +81,24 @@ function formatVersion(value: string | null): string {
61
81
  function formatLastCheckedAt(value: string | null): string {
62
82
  return value ? formatDateTime(value) : '-';
63
83
  }
64
- function getChannelLabel(channel: DesktopReleaseChannel): string {
84
+ function formatDownloadBytes(downloadedBytes: number, totalBytes: number | null): string {
85
+ const downloaded = formatBytes(downloadedBytes);
86
+ return totalBytes && totalBytes > 0 ? `${downloaded} / ${formatBytes(totalBytes)}` : downloaded;
87
+ }
88
+ function formatBytes(value: number): string {
89
+ if (!Number.isFinite(value) || value <= 0) {
90
+ return '0 B';
91
+ }
92
+ const units = ['B', 'KB', 'MB', 'GB'];
93
+ let cursor = value;
94
+ let unitIndex = 0;
95
+ while (cursor >= 1024 && unitIndex < units.length - 1) {
96
+ cursor /= 1024;
97
+ unitIndex += 1;
98
+ }
99
+ return `${cursor >= 10 || unitIndex === 0 ? cursor.toFixed(0) : cursor.toFixed(1)} ${units[unitIndex]}`;
100
+ }
101
+ function getChannelLabel(channel: UpdateSnapshot['channel']): string {
65
102
  return channel === 'beta' ? t('desktopUpdatesChannelBeta') : t('desktopUpdatesChannelStable');
66
103
  }
67
104
  function getStatusLabel(status: string): string {
@@ -74,40 +111,40 @@ function getStatusTone(status: string): string {
74
111
  if (status === 'update-available' || status === 'downloading' || status === 'checking') {
75
112
  return 'bg-amber-50 text-amber-700 ring-amber-100';
76
113
  }
77
- if (status === 'failed') {
114
+ if (status === 'failed' || status === 'blocked') {
78
115
  return 'bg-red-50 text-red-700 ring-red-100';
79
116
  }
80
117
  return 'bg-gray-100 text-gray-700 ring-gray-200';
81
118
  }
82
119
 
83
- function DesktopOnlyState() {
120
+ function RuntimeUpdateUnavailableState() {
84
121
  return (
85
122
  <PageLayout className="space-y-6">
86
- <PageHeader title={t('desktopUpdatesPageTitle')} description={t('desktopUpdatesPageDescription')} />
123
+ <PageHeader title={t('runtimeUpdatesPageTitle')} description={t('runtimeUpdatesPageDescription')} />
87
124
  <Card>
88
125
  <CardHeader>
89
- <CardTitle>{t('desktopUpdatesDesktopOnlyTitle')}</CardTitle>
90
- <CardDescription>{t('desktopUpdatesDesktopOnlyDescription')}</CardDescription>
126
+ <CardTitle>{t('runtimeUpdatesUnavailableTitle')}</CardTitle>
127
+ <CardDescription>{t('runtimeUpdatesUnavailableDescription')}</CardDescription>
91
128
  </CardHeader>
92
- <CardContent><p className="text-sm text-gray-500">{t('desktopUpdatesDesktopOnlyFutureHint')}</p></CardContent>
129
+ <CardContent><p className="text-sm text-gray-500">{t('runtimeUpdatesUnavailableHint')}</p></CardContent>
93
130
  </Card>
94
131
  </PageLayout>
95
132
  );
96
133
  }
97
134
 
98
135
  export function DesktopUpdateConfig() {
99
- const { supported, initialized, busyAction, snapshot } = useDesktopUpdateStore();
136
+ const { supported, initialized, busyAction, snapshot } = useRuntimeUpdateStore();
100
137
  useEffect(() => {
101
- void desktopUpdateManager.start();
138
+ void runtimeUpdateManager.start();
102
139
  return () => {
103
- desktopUpdateManager.stop();
140
+ runtimeUpdateManager.stop();
104
141
  };
105
142
  }, []);
106
143
  if (!initialized) {
107
144
  return <div className="p-8 text-gray-400">{t('loading')}</div>;
108
145
  }
109
146
  if (!supported || !snapshot) {
110
- return <DesktopOnlyState />;
147
+ return <RuntimeUpdateUnavailableState />;
111
148
  }
112
149
  const isChecking = busyAction === 'checking';
113
150
  const isDownloading = busyAction === 'downloading';
@@ -117,7 +154,7 @@ export function DesktopUpdateConfig() {
117
154
  const canDownload = snapshot.status === 'update-available' && !isDownloading && !isApplying;
118
155
  const canApply = snapshot.status === 'downloaded' && !isApplying;
119
156
  const overviewStats = [
120
- [t('desktopUpdatesLauncherVersion'), formatVersion(snapshot.launcherVersion)],
157
+ [t('runtimeUpdatesHostVersion'), formatVersion(snapshot.hostVersion)],
121
158
  [t('desktopUpdatesCurrentBundleVersion'), formatVersion(snapshot.currentVersion)],
122
159
  [t('desktopUpdatesAvailableVersion'), formatVersion(snapshot.availableVersion)],
123
160
  [t('desktopUpdatesLastCheckedAt'), formatLastCheckedAt(snapshot.lastCheckedAt)],
@@ -126,9 +163,9 @@ export function DesktopUpdateConfig() {
126
163
  return (
127
164
  <PageLayout className="space-y-6">
128
165
  <PageHeader
129
- title={t('desktopUpdatesPageTitle')}
130
- description={t('desktopUpdatesPageDescription')}
131
- actions={<Button variant="outline" onClick={() => void desktopUpdateManager.checkForUpdates()} disabled={isChecking || isDownloading || isApplying}><RefreshCw className={cn('mr-2 h-4 w-4', isChecking && 'animate-spin')} />{t('desktopUpdatesCheckNow')}</Button>}
166
+ title={t('runtimeUpdatesPageTitle')}
167
+ description={t('runtimeUpdatesPageDescription')}
168
+ actions={<Button variant="outline" onClick={() => void runtimeUpdateManager.checkForUpdates()} disabled={isChecking || isDownloading || isApplying}><RefreshCw className={cn('mr-2 h-4 w-4', isChecking && 'animate-spin')} />{t('desktopUpdatesCheckNow')}</Button>}
132
169
  />
133
170
  <Card>
134
171
  <CardHeader>
@@ -147,10 +184,18 @@ export function DesktopUpdateConfig() {
147
184
  {snapshot.downloadedVersion ? (
148
185
  <div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4">
149
186
  <p className="text-sm font-semibold text-emerald-800">{t('desktopUpdatesDownloadedBannerTitle')}</p>
150
- <p className="mt-1 text-sm text-emerald-700">{t('desktopUpdatesDownloadedBannerDescription').replace('{version}', snapshot.downloadedVersion)}</p>
187
+ <p className="mt-1 text-sm text-emerald-700">{t('runtimeUpdatesDownloadedBannerDescription').replace('{version}', snapshot.downloadedVersion)}</p>
188
+ </div>
189
+ ) : null}
190
+ <DownloadProgress snapshot={snapshot} />
191
+ {snapshot.status === 'blocked' ? (
192
+ <div className="rounded-2xl border border-red-200 bg-red-50/70 p-4">
193
+ <p className="text-sm font-semibold text-red-800">{t('desktopUpdatesBlockedTitle')}</p>
194
+ <p className="mt-1 text-sm text-red-700">{snapshot.errorMessage ?? t('desktopUpdatesBlockedDescription')}</p>
195
+ {snapshot.recoveryCommand ? <code className="mt-3 block rounded-lg bg-white/70 px-3 py-2 text-xs text-red-800">{snapshot.recoveryCommand}</code> : null}
151
196
  </div>
152
197
  ) : null}
153
- {snapshot.errorMessage ? <div className="rounded-2xl border border-red-200 bg-red-50/70 p-4 text-sm text-red-700">{snapshot.errorMessage}</div> : null}
198
+ {snapshot.errorMessage && snapshot.status !== 'blocked' ? <div className="rounded-2xl border border-red-200 bg-red-50/70 p-4 text-sm text-red-700">{snapshot.errorMessage}</div> : null}
154
199
  </CardContent>
155
200
  </Card>
156
201
  <Card>
@@ -165,7 +210,7 @@ export function DesktopUpdateConfig() {
165
210
  <Label>{t('desktopUpdatesReleaseChannel')}</Label>
166
211
  <p className="text-sm text-gray-500">{t('desktopUpdatesReleaseChannelHelp')}</p>
167
212
  </div>
168
- <Select value={snapshot.channel} disabled={isSwitchingChannel || isChecking || isDownloading || isApplying} onValueChange={(value) => void desktopUpdateManager.updateChannel(value as DesktopReleaseChannel)}>
213
+ <Select value={snapshot.channel} disabled={isSwitchingChannel || isChecking || isDownloading || isApplying} onValueChange={(value) => void runtimeUpdateManager.updateChannel(value as UpdateSnapshot['channel'])}>
169
214
  <SelectTrigger className="w-full max-w-sm">
170
215
  <SelectValue placeholder={t('desktopUpdatesReleaseChannel')} />
171
216
  </SelectTrigger>
@@ -182,34 +227,34 @@ export function DesktopUpdateConfig() {
182
227
  help={t('desktopUpdatesAutomaticChecksHelp')}
183
228
  checked={snapshot.preferences.automaticChecks}
184
229
  disabled={isSavingPreferences || isSwitchingChannel}
185
- onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ automaticChecks: checked })}
230
+ onCheckedChange={(checked) => void runtimeUpdateManager.updatePreferences({ automaticChecks: checked })}
186
231
  />
187
232
  <PreferenceToggle
188
233
  label={t('desktopUpdatesAutoDownload')}
189
234
  help={t('desktopUpdatesAutoDownloadHelp')}
190
235
  checked={snapshot.preferences.autoDownload}
191
236
  disabled={isSavingPreferences || isSwitchingChannel}
192
- onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ autoDownload: checked })}
237
+ onCheckedChange={(checked) => void runtimeUpdateManager.updatePreferences({ autoDownload: checked })}
193
238
  />
194
239
  </CardContent>
195
240
  </Card>
196
241
  <Card>
197
242
  <CardHeader>
198
243
  <CardTitle>{t('desktopUpdatesActionsTitle')}</CardTitle>
199
- <CardDescription>{t('desktopUpdatesActionsDescription')}</CardDescription>
244
+ <CardDescription>{t('runtimeUpdatesActionsDescription')}</CardDescription>
200
245
  </CardHeader>
201
246
  <CardContent className="flex flex-wrap items-center gap-3">
202
- <Button variant="outline" onClick={() => void desktopUpdateManager.checkForUpdates()} disabled={isChecking || isDownloading || isApplying}>
247
+ <Button variant="outline" onClick={() => void runtimeUpdateManager.checkForUpdates()} disabled={isChecking || isDownloading || isApplying}>
203
248
  <RefreshCw className={cn('mr-2 h-4 w-4', isChecking && 'animate-spin')} />
204
249
  {t('desktopUpdatesCheckNow')}
205
250
  </Button>
206
- <Button onClick={() => void desktopUpdateManager.downloadUpdate()} disabled={!canDownload}>
251
+ <Button onClick={() => void runtimeUpdateManager.downloadUpdate()} disabled={!canDownload}>
207
252
  <Download className={cn('mr-2 h-4 w-4', isDownloading && 'animate-bounce')} />
208
253
  {t('desktopUpdatesDownloadNow')}
209
254
  </Button>
210
- <Button variant="secondary" onClick={() => void desktopUpdateManager.applyDownloadedUpdate()} disabled={!canApply}>
255
+ <Button variant="secondary" onClick={() => void runtimeUpdateManager.applyDownloadedUpdate()} disabled={!canApply}>
211
256
  <RotateCw className={cn('mr-2 h-4 w-4', isApplying && 'animate-spin')} />
212
- {t('desktopUpdatesApplyNow')}
257
+ {t('runtimeUpdatesApplyNow')}
213
258
  </Button>
214
259
  {snapshot.releaseNotesUrl ? <Button variant="ghost" onClick={() => window.open(snapshot.releaseNotesUrl ?? '', '_blank', 'noopener,noreferrer')}><ExternalLink className="mr-2 h-4 w-4" />{t('desktopUpdatesReleaseNotes')}</Button> : null}
215
260
  </CardContent>
@@ -82,17 +82,6 @@ export function useSystemStatus() {
82
82
  return toSystemStatusView(state);
83
83
  }
84
84
 
85
- export function useChatRuntimeAvailability() {
86
- const state = useSystemStatusStore((store) => store.state);
87
- const view = toSystemStatusView(state);
88
- return {
89
- isBlocked: view.isChatBlocked,
90
- message: view.chatMessage,
91
- phase: view.phase,
92
- lastReadyAt: view.lastReadyAt,
93
- };
94
- }
95
-
96
85
  export function useRuntimeStatusBadgeView() {
97
86
  const state = useSystemStatusStore((store) => store.state);
98
87
  return toRuntimeStatusBadgeView(state);
@@ -1,4 +1,7 @@
1
- export { useChatRuntimeAvailability, useRuntimeControlPanelView, useRuntimeStatusBadgeView, useSystemStatus, useSystemStatusSources } from './hooks/use-system-status';
1
+ export { useRuntimeControlPanelView, useRuntimeStatusBadgeView, useSystemStatus, useSystemStatusSources } from './hooks/use-system-status';
2
2
  export { isTransientRuntimeConnectionErrorMessage, systemStatusManager } from './managers/system-status.manager';
3
+ export { runtimeUpdateManager } from './managers/runtime-update.manager';
4
+ export type { SystemStatusState, SystemStatusView } from './types/system-status.types';
3
5
  export { useSystemStatusStore } from './stores/system-status.store';
6
+ export { useRuntimeUpdateStore } from './stores/runtime-update.store';
4
7
  export { SecurityConfig } from './components/security-config';
@@ -0,0 +1,330 @@
1
+ import type { UpdatePreferences, UpdateSnapshot } from '@nextclaw/kernel';
2
+ import { applyRuntimeUpdate, checkRuntimeUpdate, downloadRuntimeUpdate, fetchRuntimeUpdate, updateRuntimeUpdateChannel, updateRuntimeUpdatePreferences } from '@/shared/lib/api';
3
+ import type { NextClawDesktopBridge } from '@/platforms/desktop';
4
+ import { t } from '@/shared/lib/i18n';
5
+ import { toast } from 'sonner';
6
+ import { useRuntimeUpdateStore, type RuntimeUpdateBusyAction } from '@/features/system-status/stores/runtime-update.store';
7
+
8
+ type RuntimeUpdateSourceKind = 'desktop-bridge' | 'runtime-host';
9
+
10
+ interface RuntimeUpdateSourceBase {
11
+ getState: () => Promise<UpdateSnapshot>;
12
+ checkForUpdates: () => Promise<UpdateSnapshot>;
13
+ downloadUpdate: () => Promise<UpdateSnapshot>;
14
+ applyDownloadedUpdate: () => Promise<UpdateSnapshot>;
15
+ updatePreferences: (preferences: Partial<UpdatePreferences>) => Promise<UpdateSnapshot>;
16
+ updateChannel: (channel: UpdateSnapshot['channel']) => Promise<UpdateSnapshot>;
17
+ }
18
+
19
+ interface DesktopBridgeRuntimeUpdateSourceContract extends RuntimeUpdateSourceBase {
20
+ kind: 'desktop-bridge';
21
+ subscribe: (listener: (snapshot: UpdateSnapshot) => void) => () => void;
22
+ }
23
+
24
+ interface HostRuntimeUpdateSourceContract extends RuntimeUpdateSourceBase {
25
+ kind: 'runtime-host';
26
+ }
27
+
28
+ const RUNTIME_HOST_POLL_INTERVAL_MS = 1000;
29
+
30
+ type RuntimeUpdateSource = DesktopBridgeRuntimeUpdateSourceContract | HostRuntimeUpdateSourceContract;
31
+
32
+ class DesktopBridgeRuntimeUpdateSource implements DesktopBridgeRuntimeUpdateSourceContract {
33
+ readonly kind = 'desktop-bridge' as const;
34
+
35
+ constructor(private readonly desktopApi: NextClawDesktopBridge) {}
36
+
37
+ subscribe = (listener: (snapshot: UpdateSnapshot) => void) => {
38
+ return this.desktopApi.onUpdateStateChanged(listener);
39
+ };
40
+
41
+ getState = async (): Promise<UpdateSnapshot> => {
42
+ return await this.desktopApi.getUpdateState();
43
+ };
44
+
45
+ checkForUpdates = async (): Promise<UpdateSnapshot> => {
46
+ return await this.desktopApi.checkForUpdates();
47
+ };
48
+
49
+ downloadUpdate = async (): Promise<UpdateSnapshot> => {
50
+ return await this.desktopApi.downloadUpdate();
51
+ };
52
+
53
+ applyDownloadedUpdate = async (): Promise<UpdateSnapshot> => {
54
+ return await this.desktopApi.applyDownloadedUpdate();
55
+ };
56
+
57
+ updatePreferences = async (preferences: Partial<UpdatePreferences>): Promise<UpdateSnapshot> => {
58
+ return await this.desktopApi.updatePreferences(preferences);
59
+ };
60
+
61
+ updateChannel = async (channel: UpdateSnapshot['channel']): Promise<UpdateSnapshot> => {
62
+ return await this.desktopApi.updateChannel(channel);
63
+ };
64
+ }
65
+
66
+ class HostRuntimeUpdateSource implements HostRuntimeUpdateSourceContract {
67
+ readonly kind = 'runtime-host' as const;
68
+
69
+ getState = async (): Promise<UpdateSnapshot> => {
70
+ return await fetchRuntimeUpdate();
71
+ };
72
+
73
+ checkForUpdates = async (): Promise<UpdateSnapshot> => {
74
+ return await checkRuntimeUpdate();
75
+ };
76
+
77
+ downloadUpdate = async (): Promise<UpdateSnapshot> => {
78
+ return await downloadRuntimeUpdate();
79
+ };
80
+
81
+ applyDownloadedUpdate = async (): Promise<UpdateSnapshot> => {
82
+ return await applyRuntimeUpdate();
83
+ };
84
+
85
+ updatePreferences = async (preferences: Partial<UpdatePreferences>): Promise<UpdateSnapshot> => {
86
+ return await updateRuntimeUpdatePreferences(preferences);
87
+ };
88
+
89
+ updateChannel = async (channel: UpdateSnapshot['channel']): Promise<UpdateSnapshot> => {
90
+ return await updateRuntimeUpdateChannel(channel);
91
+ };
92
+ }
93
+
94
+ export class RuntimeUpdateManager {
95
+ private unsubscribe: (() => void) | null = null;
96
+ private pollingTimer: number | null = null;
97
+ private subscriptionCount = 0;
98
+ private source: RuntimeUpdateSource | null = null;
99
+
100
+ start = async () => {
101
+ this.subscriptionCount += 1;
102
+ const source = this.resolveSource();
103
+ this.source = source;
104
+ if (!source) {
105
+ useRuntimeUpdateStore.setState({
106
+ supported: false,
107
+ initialized: true,
108
+ snapshot: null
109
+ });
110
+ return;
111
+ }
112
+
113
+ if (source.kind === 'desktop-bridge' && !this.unsubscribe) {
114
+ this.unsubscribe = source.subscribe((snapshot) => {
115
+ useRuntimeUpdateStore.setState({
116
+ supported: true,
117
+ initialized: true,
118
+ snapshot
119
+ });
120
+ });
121
+ }
122
+
123
+ if (source.kind === 'runtime-host') {
124
+ this.ensurePolling();
125
+ }
126
+
127
+ useRuntimeUpdateStore.setState({
128
+ supported: true,
129
+ initialized: false
130
+ });
131
+
132
+ try {
133
+ const snapshot = await source.getState();
134
+ useRuntimeUpdateStore.setState({
135
+ supported: true,
136
+ initialized: true,
137
+ snapshot
138
+ });
139
+ } catch (error) {
140
+ if (source.kind === 'runtime-host' && this.isUnsupportedError(error)) {
141
+ useRuntimeUpdateStore.setState({
142
+ supported: false,
143
+ initialized: true,
144
+ snapshot: null
145
+ });
146
+ return;
147
+ }
148
+ useRuntimeUpdateStore.setState({
149
+ supported: true,
150
+ initialized: true
151
+ });
152
+ toast.error(`${t('runtimeUpdatesLoadFailed')}: ${this.getErrorMessage(error)}`);
153
+ }
154
+ };
155
+
156
+ stop = () => {
157
+ this.subscriptionCount = Math.max(0, this.subscriptionCount - 1);
158
+ if (this.subscriptionCount > 0) {
159
+ return;
160
+ }
161
+ this.unsubscribe?.();
162
+ this.unsubscribe = null;
163
+ if (this.pollingTimer !== null && typeof window !== 'undefined') {
164
+ window.clearInterval(this.pollingTimer);
165
+ }
166
+ this.pollingTimer = null;
167
+ this.source = null;
168
+ };
169
+
170
+ checkForUpdates = async () => {
171
+ let snapshot: UpdateSnapshot;
172
+ try {
173
+ snapshot = await this.runSnapshotCommand('checking', t('runtimeUpdatesCheckFailed'), async (source) => {
174
+ return await source.checkForUpdates();
175
+ });
176
+ } catch {
177
+ return;
178
+ }
179
+
180
+ if (snapshot.status === 'up-to-date') {
181
+ toast.success(t('runtimeUpdatesAlreadyLatest'));
182
+ return;
183
+ }
184
+ if (snapshot.status === 'update-available') {
185
+ toast.success(
186
+ t('runtimeUpdatesAvailable').replace('{version}', snapshot.availableVersion ?? t('runtimeUpdatesUnknownVersion'))
187
+ );
188
+ return;
189
+ }
190
+ if (snapshot.status === 'downloaded') {
191
+ toast.success(t('runtimeUpdatesReadyToApply'));
192
+ }
193
+ };
194
+
195
+ downloadUpdate = async () => {
196
+ try {
197
+ await this.runSnapshotCommand('downloading', t('runtimeUpdatesDownloadFailed'), async (source) => {
198
+ return await source.downloadUpdate();
199
+ });
200
+ } catch {
201
+ return;
202
+ }
203
+ };
204
+
205
+ applyDownloadedUpdate = async () => {
206
+ try {
207
+ await this.runSnapshotCommand('applying', t('runtimeUpdatesApplyFailed'), async (source) => {
208
+ return await source.applyDownloadedUpdate();
209
+ });
210
+ } catch {
211
+ return;
212
+ }
213
+ };
214
+
215
+ updatePreferences = async (preferences: Partial<UpdatePreferences>) => {
216
+ try {
217
+ await this.runSnapshotCommand('saving-preferences', t('runtimeUpdatesPreferencesFailed'), async (source) => {
218
+ return await source.updatePreferences(preferences);
219
+ });
220
+ } catch {
221
+ return;
222
+ }
223
+ };
224
+
225
+ updateChannel = async (channel: UpdateSnapshot['channel']) => {
226
+ const currentChannel = useRuntimeUpdateStore.getState().snapshot?.channel;
227
+ if (currentChannel === channel) {
228
+ return;
229
+ }
230
+
231
+ let snapshot: UpdateSnapshot;
232
+ try {
233
+ snapshot = await this.runSnapshotCommand('switching-channel', t('runtimeUpdatesChannelChangeFailed'), async (source) => {
234
+ return await source.updateChannel(channel);
235
+ });
236
+ } catch {
237
+ return;
238
+ }
239
+
240
+ if (snapshot.status === 'update-available' && snapshot.availableVersion) {
241
+ toast.success(
242
+ t('runtimeUpdatesChannelChangedWithUpdate')
243
+ .replace('{channel}', this.getChannelLabel(channel))
244
+ .replace('{version}', snapshot.availableVersion)
245
+ );
246
+ return;
247
+ }
248
+
249
+ toast.success(t('runtimeUpdatesChannelChanged').replace('{channel}', this.getChannelLabel(channel)));
250
+ };
251
+
252
+ private ensurePolling = () => {
253
+ if (this.pollingTimer !== null || typeof window === 'undefined') {
254
+ return;
255
+ }
256
+ this.pollingTimer = window.setInterval(() => {
257
+ void this.refreshSnapshot();
258
+ }, RUNTIME_HOST_POLL_INTERVAL_MS);
259
+ };
260
+
261
+ private refreshSnapshot = async () => {
262
+ if (!this.source || this.source.kind !== 'runtime-host') {
263
+ return;
264
+ }
265
+ try {
266
+ const snapshot = await this.source.getState();
267
+ useRuntimeUpdateStore.setState({
268
+ supported: true,
269
+ initialized: true,
270
+ snapshot
271
+ });
272
+ } catch {
273
+ // keep the latest successful snapshot visible
274
+ }
275
+ };
276
+
277
+ private runSnapshotCommand = async (
278
+ busyAction: RuntimeUpdateBusyAction,
279
+ fallbackMessage: string,
280
+ job: (source: RuntimeUpdateSource) => Promise<UpdateSnapshot>
281
+ ): Promise<UpdateSnapshot> => {
282
+ const source = this.source ?? this.resolveSource();
283
+ if (!source) {
284
+ throw new Error(t('runtimeUpdatesUnavailableDescription'));
285
+ }
286
+
287
+ this.source = source;
288
+ useRuntimeUpdateStore.setState({ busyAction });
289
+ try {
290
+ const snapshot = await job(source);
291
+ useRuntimeUpdateStore.setState({ snapshot });
292
+ return snapshot;
293
+ } catch (error) {
294
+ toast.error(`${fallbackMessage}: ${this.getErrorMessage(error)}`);
295
+ throw error;
296
+ } finally {
297
+ useRuntimeUpdateStore.setState({ busyAction: null });
298
+ }
299
+ };
300
+
301
+ private resolveSource = (): RuntimeUpdateSource | null => {
302
+ const desktopApi = this.getDesktopApi();
303
+ if (desktopApi) {
304
+ return new DesktopBridgeRuntimeUpdateSource(desktopApi);
305
+ }
306
+ return new HostRuntimeUpdateSource();
307
+ };
308
+
309
+ private getDesktopApi = (): NextClawDesktopBridge | null => {
310
+ if (typeof window === 'undefined') {
311
+ return null;
312
+ }
313
+ return window.nextclawDesktop ?? null;
314
+ };
315
+
316
+ private isUnsupportedError = (error: unknown): boolean => {
317
+ const message = this.getErrorMessage(error).toLowerCase();
318
+ return message.includes('404') || message.includes('not found') || message.includes('endpoint not found');
319
+ };
320
+
321
+ private getErrorMessage = (error: unknown): string => {
322
+ return error instanceof Error ? error.message : t('error');
323
+ };
324
+
325
+ private getChannelLabel = (channel: UpdateSnapshot['channel']): string => {
326
+ return channel === 'beta' ? t('desktopUpdatesChannelBeta') : t('desktopUpdatesChannelStable');
327
+ };
328
+ }
329
+
330
+ export const runtimeUpdateManager = new RuntimeUpdateManager();
@@ -1,7 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import type { BootstrapStatusView } from '@/shared/lib/api';
3
3
  import { appQueryClient } from '@/app-query-client';
4
- import { t } from '@/shared/lib/i18n';
5
4
  import {
6
5
  isTransientRuntimeConnectionErrorMessage,
7
6
  systemStatusManager,
@@ -120,30 +119,6 @@ describe('systemStatusManager', () => {
120
119
  expect(useSystemStatusStore.getState().state.lifecyclePhase).toBe('stalled');
121
120
  });
122
121
 
123
- it('maps transient chat errors to friendly recovery copy while recovering', () => {
124
- systemStatusManager.reportBootstrapStatus(readyBootstrapStatus);
125
- systemStatusManager.handleConnectionInterrupted('Failed to fetch');
126
-
127
- expect(
128
- systemStatusManager.getDisplayMessage(
129
- 'NCP fetch failed for POST /api/ncp/agent: Error: Failed to fetch'
130
- )
131
- ).toBe(t('runtimeControlRecoveringHelp'));
132
- });
133
-
134
- it('suppresses transient transport errors after recovery stalls', async () => {
135
- systemStatusManager.reportBootstrapStatus(readyBootstrapStatus);
136
- systemStatusManager.handleConnectionInterrupted('Failed to fetch');
137
-
138
- await vi.advanceTimersByTimeAsync(30_000);
139
-
140
- expect(
141
- systemStatusManager.getDisplayMessage(
142
- 'NCP fetch failed for POST /api/ncp/agent: Error: Failed to fetch'
143
- )
144
- ).toBeNull();
145
- });
146
-
147
122
  it('restores readiness from stalled once the realtime connection reopens', async () => {
148
123
  systemStatusManager.reportBootstrapStatus(readyBootstrapStatus);
149
124
  systemStatusManager.handleConnectionInterrupted('websocket error');
@@ -15,7 +15,6 @@ import type { NextClawDesktopBridge } from '@/platforms/desktop';
15
15
  import { t } from '@/shared/lib/i18n';
16
16
  import {
17
17
  buildActiveSystemActionState,
18
- resolveChatRuntimeMessage,
19
18
  toSystemStatusView,
20
19
  } from '@/features/system-status/utils/system-status.utils';
21
20
  import {
@@ -245,35 +244,7 @@ export class SystemStatusManager {
245
244
  }
246
245
  };
247
246
 
248
- isChatInteractionBlocked = (): boolean => {
249
- return toSystemStatusView(this.getState()).isChatBlocked;
250
- };
251
-
252
- getDisplayMessage = (message: string | null | undefined): string | null => {
253
- if (!message?.trim()) {
254
- return resolveChatRuntimeMessage(this.getState());
255
- }
256
- const { phase } = toSystemStatusView(this.getState());
257
- if (
258
- phase === 'service-transitioning' &&
259
- this.getState().activeSystemAction?.message?.trim()
260
- ) {
261
- return this.getState().activeSystemAction?.message?.trim() ?? null;
262
- }
263
- if (
264
- phase === 'recovering' &&
265
- isTransientRuntimeConnectionErrorMessage(message)
266
- ) {
267
- return t('runtimeControlRecoveringHelp');
268
- }
269
- if (
270
- phase === 'stalled' &&
271
- isTransientRuntimeConnectionErrorMessage(message)
272
- ) {
273
- return null;
274
- }
275
- return message;
276
- };
247
+ getStatusView = () => toSystemStatusView(this.getState());
277
248
 
278
249
  resetForTests = (): void => {
279
250
  this.clearRecoveryTimeout();