@nextclaw/ui 0.12.5 → 0.12.6

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 (113) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/ChannelsList-D8p4OlM6.js +8 -0
  3. package/dist/assets/ChatPage-A45t1Rmf.js +58 -0
  4. package/dist/assets/DocBrowser-B2MpsnU9.js +1 -0
  5. package/dist/assets/{DocBrowser-QUZ3nfmH.js → DocBrowser-Cse_F8Nn.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-CpiIfhJO.js → DocBrowserContext-Bai1WU2H.js} +1 -1
  7. package/dist/assets/{LogoBadge-BUK13xK5.js → LogoBadge-BdxMPc9v.js} +1 -1
  8. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +1 -0
  9. package/dist/assets/MarketplacePage-BbpAkllU.js +49 -0
  10. package/dist/assets/{McpMarketplacePage-BG4T_Pcx.js → McpMarketplacePage-CxPFOgxv.js} +2 -2
  11. package/dist/assets/ModelConfig-3GLqQ5GY.js +1 -0
  12. package/dist/assets/{ProviderScopedModelInput-DGn6sFEN.js → ProviderScopedModelInput-BYNouw-i.js} +1 -1
  13. package/dist/assets/ProvidersList-BR1gJ4Dm.js +1 -0
  14. package/dist/assets/{RemoteAccessPage-ff15qO-c.js → RemoteAccessPage-DyYVWsyK.js} +1 -1
  15. package/dist/assets/{RuntimeConfig-TgPandXF.js → RuntimeConfig-ChdfK4Y_.js} +1 -1
  16. package/dist/assets/SearchConfig-DTeJvp8m.js +1 -0
  17. package/dist/assets/{SecretsConfig-Bew4EF2A.js → SecretsConfig-CCYO6NcV.js} +2 -2
  18. package/dist/assets/SessionsConfig-Du39vDgt.js +2 -0
  19. package/dist/assets/app-query-client-Dr5d-K8d.js +1 -0
  20. package/dist/assets/{book-open-CJG8Yz3U.js → book-open-Da4OEPqB.js} +1 -1
  21. package/dist/assets/chat-session-display-CAlPrnlV.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-D5b3Iyas.js → chunk-JZWAC4HX-CoFVxHXV.js} +1 -1
  23. package/dist/assets/client-CSk58DcF.js +7 -0
  24. package/dist/assets/config-D8KzikVB.js +1 -0
  25. package/dist/assets/{createLucideIcon-_FMJqZw2.js → createLucideIcon-83gaZMtv.js} +1 -1
  26. package/dist/assets/desktop-update-config-CfoVwf-w.js +1 -0
  27. package/dist/assets/dist-aTmhMDVh.js +9 -0
  28. package/dist/assets/{dist-B1fpOuON.js → dist-toEYs-MZ.js} +1 -1
  29. package/dist/assets/{external-link-b7gAJWYY.js → external-link-QQ0TC6X4.js} +1 -1
  30. package/dist/assets/{hash-Bhy4TwfZ.js → hash-DaFBEkmi.js} +1 -1
  31. package/dist/assets/i18n-C3jb83S6.js +1 -0
  32. package/dist/assets/index-CE4N7ItL.css +1 -0
  33. package/dist/assets/index-riX7Sg0_.js +6 -0
  34. package/dist/assets/infiniteQueryBehavior-BmHX_ayZ.js +1 -0
  35. package/dist/assets/loader-circle-BjMg63eu.js +1 -0
  36. package/dist/assets/{logos-GMeYU9vc.js → logos-Dzlz30M3.js} +1 -1
  37. package/dist/assets/{page-layout-C8UbWuMt.js → page-layout-D2eRufRQ.js} +1 -1
  38. package/dist/assets/plus-CIXME2pD.js +1 -0
  39. package/dist/assets/{popover-8HSx9wQj.js → popover-BSXxm5bj.js} +1 -1
  40. package/dist/assets/{refresh-ccw-CA4_C7Zg.js → refresh-ccw-B3zMtN-_.js} +1 -1
  41. package/dist/assets/refresh-cw-DlZkIHnJ.js +1 -0
  42. package/dist/assets/{save-BtvMy4lk.js → save-Us9fg4Sj.js} +1 -1
  43. package/dist/assets/search-B_Qr0f6C.js +1 -0
  44. package/dist/assets/security-config-BGWYwxNr.js +1 -0
  45. package/dist/assets/{select-xp_Ac8ip.js → select-DLYqySQK.js} +1 -1
  46. package/dist/assets/skeleton-CYQJazv6.js +1 -0
  47. package/dist/assets/{status-dot-Cn4Pp7DZ.js → status-dot-DGayudyB.js} +1 -1
  48. package/dist/assets/{switch-BTi6UOij.js → switch-Dz2ScsKx.js} +1 -1
  49. package/dist/assets/{tabs-custom-BiiN8DME.js → tabs-custom-CdKyjiGk.js} +1 -1
  50. package/dist/assets/{trash-2-BpsF0N-r.js → trash-2-Db-mZOZs.js} +1 -1
  51. package/dist/assets/use-infinite-scroll-loader-DBJX5hj0.js +1 -0
  52. package/dist/assets/{useConfirmDialog-BJIwUZjH.js → useConfirmDialog-DL0a-oGC.js} +1 -1
  53. package/dist/assets/useMutation-BdZm-9PL.js +1 -0
  54. package/dist/assets/x-B8Tho_xC.js +1 -0
  55. package/dist/index.html +20 -19
  56. package/package.json +5 -5
  57. package/src/App.tsx +2 -0
  58. package/src/api/raw-client.test.ts +37 -0
  59. package/src/api/raw-client.ts +51 -8
  60. package/src/components/chat/ChatConversationPanel.test.tsx +161 -1
  61. package/src/components/chat/ChatSidebar.test.tsx +109 -4
  62. package/src/components/chat/ChatSidebar.tsx +62 -9
  63. package/src/components/chat/chat-child-session-panel.tsx +56 -18
  64. package/src/components/chat/chat-sidebar-session-item.tsx +189 -121
  65. package/src/components/chat/containers/chat-message-list.container.test.tsx +21 -3
  66. package/src/components/chat/containers/chat-message-list.container.tsx +14 -0
  67. package/src/components/chat/managers/chat-session-list.manager.test.ts +34 -0
  68. package/src/components/chat/managers/chat-session-list.manager.ts +13 -0
  69. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +1 -1
  70. package/src/components/chat/ncp/ncp-app-client-fetch.ts +45 -5
  71. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +18 -5
  72. package/src/components/chat/stores/chat-session-list.store.ts +96 -5
  73. package/src/components/config/ProviderForm.tsx +9 -15
  74. package/src/components/config/desktop-update-config.tsx +230 -0
  75. package/src/components/layout/Sidebar.tsx +6 -1
  76. package/src/components/layout/sidebar.layout.test.tsx +1 -0
  77. package/src/desktop/desktop-update.types.ts +36 -0
  78. package/src/desktop/managers/desktop-update.manager.ts +163 -0
  79. package/src/desktop/stores/desktop-update.store.ts +18 -0
  80. package/src/lib/desktop-update-labels.utils.ts +72 -0
  81. package/src/lib/i18n.chat.ts +13 -0
  82. package/src/lib/i18n.ts +3 -9
  83. package/src/lib/ui-document-title.ts +1 -0
  84. package/src/transport/local.transport.ts +57 -18
  85. package/src/vite-env.d.ts +10 -0
  86. package/dist/assets/ChannelsList-C6-lh55g.js +0 -8
  87. package/dist/assets/ChatPage-DOW0gPc2.js +0 -45
  88. package/dist/assets/DocBrowser-CGyeswYP.js +0 -1
  89. package/dist/assets/MarketplacePage-BDVwhIYE.js +0 -1
  90. package/dist/assets/MarketplacePage-LnKKL3xK.js +0 -49
  91. package/dist/assets/ModelConfig-LtWuogIw.js +0 -1
  92. package/dist/assets/ProvidersList-ma-_MlLo.js +0 -1
  93. package/dist/assets/SearchConfig-C9iBt7pl.js +0 -1
  94. package/dist/assets/SessionsConfig-2r2yAGZg.js +0 -2
  95. package/dist/assets/chat-session-display-DkAC5OMC.js +0 -1
  96. package/dist/assets/config-zvnxSXSP.js +0 -1
  97. package/dist/assets/dist-BCXX7FD-.js +0 -15
  98. package/dist/assets/i18n-DJg9BPYk.js +0 -1
  99. package/dist/assets/index-BoJbxdvZ.css +0 -1
  100. package/dist/assets/index-CtlT4E9Y.js +0 -6
  101. package/dist/assets/infiniteQueryBehavior-CTcVlD9s.js +0 -1
  102. package/dist/assets/loader-circle-B60I0hEk.js +0 -1
  103. package/dist/assets/plus-CR7RfK3H.js +0 -1
  104. package/dist/assets/react-BB4jko2M.js +0 -1
  105. package/dist/assets/search-C60UA27E.js +0 -1
  106. package/dist/assets/security-config-BkFDYZ6j.js +0 -1
  107. package/dist/assets/skeleton-uxz_5h3A.js +0 -1
  108. package/dist/assets/use-infinite-scroll-loader-C8jBv11-.js +0 -1
  109. package/dist/assets/useMutation-BjBOKHj_.js +0 -1
  110. package/dist/assets/x-BfTu-g7D.js +0 -1
  111. package/src/components/chat/ChatSessionsSidebar.tsx +0 -100
  112. /package/dist/assets/{config-hints-WtpHP_DW.js → config-hints-GSUMvmSo.js} +0 -0
  113. /package/dist/assets/{config-layout-LQ10ozRC.js → config-layout-CgBMG7OL.js} +0 -0
@@ -1,5 +1,6 @@
1
- import { create } from 'zustand';
1
+ import { create, type StateCreator } from 'zustand';
2
2
  import { createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
3
+ import type { SessionRunStatus } from '@/lib/session-run-status';
3
4
 
4
5
  export type ChatSessionListMode = 'time-first' | 'project-first';
5
6
 
@@ -11,11 +12,47 @@ export type ChatSessionListSnapshot = {
11
12
  listMode: ChatSessionListMode;
12
13
  };
13
14
 
15
+ export function hasUnreadSessionUpdate(
16
+ updatedAt: string | null | undefined,
17
+ readUpdatedAt: string | undefined,
18
+ ): boolean {
19
+ const normalizedUpdatedAt = updatedAt?.trim();
20
+ if (!normalizedUpdatedAt) {
21
+ return false;
22
+ }
23
+ const normalizedReadUpdatedAt = readUpdatedAt?.trim();
24
+ if (!normalizedReadUpdatedAt) {
25
+ return true;
26
+ }
27
+ return normalizedUpdatedAt.localeCompare(normalizedReadUpdatedAt) > 0;
28
+ }
29
+
30
+ export function shouldShowUnreadSessionIndicator(params: {
31
+ active: boolean;
32
+ updatedAt: string | null | undefined;
33
+ readUpdatedAt: string | undefined;
34
+ runStatus?: SessionRunStatus;
35
+ }): boolean {
36
+ const { active, readUpdatedAt, runStatus, updatedAt } = params;
37
+ if (active || runStatus === 'running') {
38
+ return false;
39
+ }
40
+ return hasUnreadSessionUpdate(updatedAt, readUpdatedAt);
41
+ }
42
+
14
43
  type ChatSessionListStore = {
15
44
  snapshot: ChatSessionListSnapshot;
45
+ readUpdatedAtBySessionKey: Record<string, string>;
46
+ hasHydratedReadWatermarks: boolean;
16
47
  setSnapshot: (patch: Partial<ChatSessionListSnapshot>) => void;
48
+ markSessionRead: (sessionKey: string, updatedAt: string | null | undefined) => void;
49
+ hydrateReadWatermarks: (
50
+ entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
51
+ ) => void;
17
52
  };
18
53
 
54
+ type ChatSessionListStoreSet = Parameters<StateCreator<ChatSessionListStore>>[0];
55
+
19
56
  const initialSnapshot: ChatSessionListSnapshot = {
20
57
  selectedSessionKey: null,
21
58
  draftSessionKey: createNcpSessionId(),
@@ -24,13 +61,67 @@ const initialSnapshot: ChatSessionListSnapshot = {
24
61
  listMode: 'time-first'
25
62
  };
26
63
 
27
- export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
28
- snapshot: initialSnapshot,
29
- setSnapshot: (patch) =>
64
+ function createSetSnapshotAction(set: ChatSessionListStoreSet) {
65
+ return (patch: Partial<ChatSessionListSnapshot>) =>
30
66
  set((state) => ({
31
67
  snapshot: {
32
68
  ...state.snapshot,
33
69
  ...patch
34
70
  }
35
- }))
71
+ }));
72
+ }
73
+
74
+ function createMarkSessionReadAction(set: ChatSessionListStoreSet) {
75
+ return (sessionKey: string, updatedAt: string | null | undefined) =>
76
+ set((state) => {
77
+ const normalizedSessionKey = sessionKey.trim();
78
+ const normalizedUpdatedAt = updatedAt?.trim();
79
+ if (!normalizedSessionKey || !normalizedUpdatedAt) {
80
+ return state;
81
+ }
82
+ if (state.readUpdatedAtBySessionKey[normalizedSessionKey] === normalizedUpdatedAt) {
83
+ return state;
84
+ }
85
+ return {
86
+ ...state,
87
+ readUpdatedAtBySessionKey: {
88
+ ...state.readUpdatedAtBySessionKey,
89
+ [normalizedSessionKey]: normalizedUpdatedAt
90
+ }
91
+ };
92
+ });
93
+ }
94
+
95
+ function createHydrateReadWatermarksAction(set: ChatSessionListStoreSet) {
96
+ return (
97
+ entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
98
+ ) =>
99
+ set((state) => {
100
+ if (state.hasHydratedReadWatermarks) {
101
+ return state;
102
+ }
103
+ const nextReadUpdatedAtBySessionKey = { ...state.readUpdatedAtBySessionKey };
104
+ for (const entry of entries) {
105
+ const normalizedSessionKey = entry.sessionKey.trim();
106
+ const normalizedUpdatedAt = entry.updatedAt?.trim();
107
+ if (!normalizedSessionKey || !normalizedUpdatedAt || nextReadUpdatedAtBySessionKey[normalizedSessionKey]) {
108
+ continue;
109
+ }
110
+ nextReadUpdatedAtBySessionKey[normalizedSessionKey] = normalizedUpdatedAt;
111
+ }
112
+ return {
113
+ ...state,
114
+ hasHydratedReadWatermarks: true,
115
+ readUpdatedAtBySessionKey: nextReadUpdatedAtBySessionKey
116
+ };
117
+ });
118
+ }
119
+
120
+ export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
121
+ snapshot: initialSnapshot,
122
+ readUpdatedAtBySessionKey: {},
123
+ hasHydratedReadWatermarks: false,
124
+ setSnapshot: createSetSnapshotAction(set),
125
+ markSessionRead: createMarkSessionReadAction(set),
126
+ hydrateReadWatermarks: createHydrateReadWatermarksAction(set)
36
127
  }));
@@ -143,6 +143,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
143
143
  () => providerAuth?.methods ?? [],
144
144
  [providerAuth?.methods]
145
145
  );
146
+ const supportsWireApi = Boolean(providerSpec?.supportsWireApi) || isCustomProvider;
146
147
  const providerAuthMethodOptions = useMemo(
147
148
  () =>
148
149
  providerAuthMethods.map((method) => ({
@@ -174,25 +175,18 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
174
175
  () => providerAuthMethods.find((method) => method.id === resolvedAuthMethodId),
175
176
  [providerAuthMethods, resolvedAuthMethodId]
176
177
  );
177
- const selectedAuthMethodHint =
178
- selectedAuthMethod?.hint?.[language] || selectedAuthMethod?.hint?.en || '';
179
178
  const shouldUseAuthMethodPills = shouldUsePillSelector({
180
179
  required: providerAuth?.kind === 'device_code',
181
180
  hasDefault: Boolean(providerAuth?.defaultMethodId?.trim()),
182
181
  optionCount: providerAuthMethods.length
183
182
  });
184
- const providerAuthNote =
185
- providerAuth?.note?.[language] ||
186
- providerAuth?.note?.en ||
187
- providerAuth?.displayName ||
188
- '';
189
183
  const wireApiOptions = providerSpec?.wireApiOptions || ['auto', 'chat', 'responses'];
190
184
  const wireApiSelectOptions: PillSelectOption[] = wireApiOptions.map((option) => ({
191
185
  value: option,
192
186
  label: option === 'chat' ? t('wireApiChat') : option === 'responses' ? t('wireApiResponses') : t('wireApiAuto')
193
187
  }));
194
188
  const shouldUseWireApiPills = shouldUsePillSelector({
195
- required: Boolean(providerSpec?.supportsWireApi),
189
+ required: supportsWireApi,
196
190
  hasDefault: typeof providerSpec?.defaultWireApi === 'string' && providerSpec.defaultWireApi.length > 0,
197
191
  optionCount: wireApiSelectOptions.length
198
192
  });
@@ -302,7 +296,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
302
296
  const apiKeyChanged = apiKey.trim().length > 0;
303
297
  const apiBaseChanged = apiBase.trim() !== currentApiBase.trim();
304
298
  const headersChanged = !headersEqual(extraHeaders, currentHeaders);
305
- const wireApiChanged = providerSpec?.supportsWireApi ? wireApi !== currentWireApi : false;
299
+ const wireApiChanged = supportsWireApi ? wireApi !== currentWireApi : false;
306
300
  const modelsChanged = !modelListsEqual(models, currentEditableModels);
307
301
  const modelThinkingChanged = !modelThinkingEqual(modelThinking, currentModelThinking);
308
302
  const displayNameChanged = isCustomProvider
@@ -331,7 +325,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
331
325
  currentApiBase,
332
326
  extraHeaders,
333
327
  currentHeaders,
334
- providerSpec?.supportsWireApi,
328
+ supportsWireApi,
335
329
  wireApi,
336
330
  currentWireApi,
337
331
  models,
@@ -422,7 +416,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
422
416
  payload.extraHeaders = normalizedHeaders;
423
417
  }
424
418
 
425
- if (providerSpec?.supportsWireApi && wireApi !== currentWireApi) {
419
+ if (supportsWireApi && wireApi !== currentWireApi) {
426
420
  payload.wireApi = wireApi;
427
421
  }
428
422
  if (!modelListsEqual(models, currentEditableModels)) {
@@ -450,7 +444,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
450
444
  if (apiKey.trim().length > 0) {
451
445
  payload.apiKey = apiKey.trim();
452
446
  }
453
- if (providerSpec?.supportsWireApi) {
447
+ if (supportsWireApi) {
454
448
  payload.wireApi = wireApi;
455
449
  }
456
450
 
@@ -608,10 +602,10 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
608
602
 
609
603
  <ProviderAuthSection
610
604
  providerAuth={providerAuth}
611
- providerAuthNote={providerAuthNote}
605
+ providerAuthNote={providerAuth?.note?.[language] || providerAuth?.note?.en || providerAuth?.displayName || ''}
612
606
  providerAuthMethodOptions={providerAuthMethodOptions}
613
607
  providerAuthMethodsCount={providerAuthMethods.length}
614
- selectedAuthMethodHint={selectedAuthMethodHint}
608
+ selectedAuthMethodHint={selectedAuthMethod?.hint?.[language] || selectedAuthMethod?.hint?.en || ''}
615
609
  shouldUseAuthMethodPills={shouldUseAuthMethodPills}
616
610
  resolvedAuthMethodId={resolvedAuthMethodId}
617
611
  onAuthMethodChange={setAuthMethodId}
@@ -663,7 +657,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
663
657
  <ProviderAdvancedSettingsSection
664
658
  showAdvanced={showAdvanced}
665
659
  onShowAdvancedChange={setShowAdvanced}
666
- supportsWireApi={Boolean(providerSpec.supportsWireApi)}
660
+ supportsWireApi={supportsWireApi}
667
661
  wireApiLabel={wireApiHint?.label ?? t('wireApi')}
668
662
  wireApi={wireApi}
669
663
  onWireApiChange={setWireApi}
@@ -0,0 +1,230 @@
1
+ import { useEffect } from 'react';
2
+ import { Button } from '@/components/ui/button';
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
4
+ import { Label } from '@/components/ui/label';
5
+ import { Switch } from '@/components/ui/switch';
6
+ import { PageHeader, PageLayout } from '@/components/layout/page-layout';
7
+ import { desktopUpdateManager } from '@/desktop/managers/desktop-update.manager';
8
+ import { useDesktopUpdateStore } from '@/desktop/stores/desktop-update.store';
9
+ import { formatDateTime, t } from '@/lib/i18n';
10
+ import { cn } from '@/lib/utils';
11
+ import { Download, ExternalLink, RefreshCw, RotateCw } from 'lucide-react';
12
+
13
+ function formatVersion(value: string | null): string {
14
+ return value?.trim() || '-';
15
+ }
16
+
17
+ function formatLastCheckedAt(value: string | null): string {
18
+ return value ? formatDateTime(value) : '-';
19
+ }
20
+
21
+ function getStatusLabel(status: string): string {
22
+ if (status === 'checking') {
23
+ return t('desktopUpdatesStatusChecking');
24
+ }
25
+ if (status === 'update-available') {
26
+ return t('desktopUpdatesStatusAvailable');
27
+ }
28
+ if (status === 'downloading') {
29
+ return t('desktopUpdatesStatusDownloading');
30
+ }
31
+ if (status === 'downloaded') {
32
+ return t('desktopUpdatesStatusDownloaded');
33
+ }
34
+ if (status === 'up-to-date') {
35
+ return t('desktopUpdatesStatusUpToDate');
36
+ }
37
+ if (status === 'failed') {
38
+ return t('desktopUpdatesStatusFailed');
39
+ }
40
+ return t('desktopUpdatesStatusIdle');
41
+ }
42
+
43
+ function getStatusTone(status: string): string {
44
+ if (status === 'downloaded') {
45
+ return 'bg-emerald-50 text-emerald-700 ring-emerald-100';
46
+ }
47
+ if (status === 'update-available' || status === 'downloading' || status === 'checking') {
48
+ return 'bg-amber-50 text-amber-700 ring-amber-100';
49
+ }
50
+ if (status === 'failed') {
51
+ return 'bg-red-50 text-red-700 ring-red-100';
52
+ }
53
+ return 'bg-gray-100 text-gray-700 ring-gray-200';
54
+ }
55
+
56
+ export function DesktopUpdateConfig() {
57
+ const { supported, initialized, busyAction, snapshot } = useDesktopUpdateStore();
58
+
59
+ useEffect(() => {
60
+ void desktopUpdateManager.start();
61
+ return () => {
62
+ desktopUpdateManager.stop();
63
+ };
64
+ }, []);
65
+
66
+ if (!initialized) {
67
+ return <div className="p-8 text-gray-400">{t('loading')}</div>;
68
+ }
69
+
70
+ if (!supported || !snapshot) {
71
+ return (
72
+ <PageLayout className="space-y-6">
73
+ <PageHeader
74
+ title={t('desktopUpdatesPageTitle')}
75
+ description={t('desktopUpdatesPageDescription')}
76
+ />
77
+ <Card>
78
+ <CardHeader>
79
+ <CardTitle>{t('desktopUpdatesDesktopOnlyTitle')}</CardTitle>
80
+ <CardDescription>{t('desktopUpdatesDesktopOnlyDescription')}</CardDescription>
81
+ </CardHeader>
82
+ <CardContent>
83
+ <p className="text-sm text-gray-500">{t('desktopUpdatesDesktopOnlyFutureHint')}</p>
84
+ </CardContent>
85
+ </Card>
86
+ </PageLayout>
87
+ );
88
+ }
89
+
90
+ const isChecking = busyAction === 'checking';
91
+ const isDownloading = busyAction === 'downloading';
92
+ const isApplying = busyAction === 'applying';
93
+ const isSavingPreferences = busyAction === 'saving-preferences';
94
+ const canDownload = snapshot.status === 'update-available' && !isDownloading && !isApplying;
95
+ const canApply = snapshot.status === 'downloaded' && !isApplying;
96
+
97
+ return (
98
+ <PageLayout className="space-y-6">
99
+ <PageHeader
100
+ title={t('desktopUpdatesPageTitle')}
101
+ description={t('desktopUpdatesPageDescription')}
102
+ actions={(
103
+ <Button
104
+ variant="outline"
105
+ onClick={() => void desktopUpdateManager.checkForUpdates()}
106
+ disabled={isChecking || isDownloading || isApplying}
107
+ >
108
+ <RefreshCw className={cn('mr-2 h-4 w-4', isChecking && 'animate-spin')} />
109
+ {t('desktopUpdatesCheckNow')}
110
+ </Button>
111
+ )}
112
+ />
113
+
114
+ <Card>
115
+ <CardHeader>
116
+ <CardTitle>{t('desktopUpdatesOverviewTitle')}</CardTitle>
117
+ <CardDescription>{t('desktopUpdatesOverviewDescription')}</CardDescription>
118
+ </CardHeader>
119
+ <CardContent className="space-y-5">
120
+ <div className="flex flex-wrap items-center gap-3">
121
+ <span className="text-sm font-medium text-gray-700">{t('desktopUpdatesStatusLabel')}</span>
122
+ <span className={cn('inline-flex rounded-full px-3 py-1 text-xs font-medium ring-1', getStatusTone(snapshot.status))}>
123
+ {getStatusLabel(snapshot.status)}
124
+ </span>
125
+ </div>
126
+
127
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
128
+ <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
129
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesLauncherVersion')}</p>
130
+ <p className="mt-2 text-base font-semibold text-gray-900">{formatVersion(snapshot.launcherVersion)}</p>
131
+ </div>
132
+ <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
133
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesCurrentBundleVersion')}</p>
134
+ <p className="mt-2 text-base font-semibold text-gray-900">{formatVersion(snapshot.currentVersion)}</p>
135
+ </div>
136
+ <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
137
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesAvailableVersion')}</p>
138
+ <p className="mt-2 text-base font-semibold text-gray-900">{formatVersion(snapshot.availableVersion)}</p>
139
+ </div>
140
+ <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
141
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesLastCheckedAt')}</p>
142
+ <p className="mt-2 text-base font-semibold text-gray-900">{formatLastCheckedAt(snapshot.lastCheckedAt)}</p>
143
+ </div>
144
+ </div>
145
+
146
+ {snapshot.downloadedVersion ? (
147
+ <div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4">
148
+ <p className="text-sm font-semibold text-emerald-800">{t('desktopUpdatesDownloadedBannerTitle')}</p>
149
+ <p className="mt-1 text-sm text-emerald-700">
150
+ {t('desktopUpdatesDownloadedBannerDescription').replace('{version}', snapshot.downloadedVersion)}
151
+ </p>
152
+ </div>
153
+ ) : null}
154
+
155
+ {snapshot.errorMessage ? (
156
+ <div className="rounded-2xl border border-red-200 bg-red-50/70 p-4 text-sm text-red-700">
157
+ {snapshot.errorMessage}
158
+ </div>
159
+ ) : null}
160
+ </CardContent>
161
+ </Card>
162
+
163
+ <Card>
164
+ <CardHeader>
165
+ <CardTitle>{t('desktopUpdatesPreferencesTitle')}</CardTitle>
166
+ <CardDescription>{t('desktopUpdatesPreferencesDescription')}</CardDescription>
167
+ </CardHeader>
168
+ <CardContent className="space-y-5">
169
+ <div className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 p-4">
170
+ <div className="space-y-1">
171
+ <Label>{t('desktopUpdatesAutomaticChecks')}</Label>
172
+ <p className="text-sm text-gray-500">{t('desktopUpdatesAutomaticChecksHelp')}</p>
173
+ </div>
174
+ <Switch
175
+ checked={snapshot.preferences.automaticChecks}
176
+ disabled={isSavingPreferences}
177
+ onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ automaticChecks: checked })}
178
+ />
179
+ </div>
180
+
181
+ <div className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 p-4">
182
+ <div className="space-y-1">
183
+ <Label>{t('desktopUpdatesAutoDownload')}</Label>
184
+ <p className="text-sm text-gray-500">{t('desktopUpdatesAutoDownloadHelp')}</p>
185
+ </div>
186
+ <Switch
187
+ checked={snapshot.preferences.autoDownload}
188
+ disabled={isSavingPreferences}
189
+ onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ autoDownload: checked })}
190
+ />
191
+ </div>
192
+ </CardContent>
193
+ </Card>
194
+
195
+ <Card>
196
+ <CardHeader>
197
+ <CardTitle>{t('desktopUpdatesActionsTitle')}</CardTitle>
198
+ <CardDescription>{t('desktopUpdatesActionsDescription')}</CardDescription>
199
+ </CardHeader>
200
+ <CardContent className="flex flex-wrap items-center gap-3">
201
+ <Button
202
+ variant="outline"
203
+ onClick={() => void desktopUpdateManager.checkForUpdates()}
204
+ disabled={isChecking || isDownloading || isApplying}
205
+ >
206
+ <RefreshCw className={cn('mr-2 h-4 w-4', isChecking && 'animate-spin')} />
207
+ {t('desktopUpdatesCheckNow')}
208
+ </Button>
209
+
210
+ <Button onClick={() => void desktopUpdateManager.downloadUpdate()} disabled={!canDownload}>
211
+ <Download className={cn('mr-2 h-4 w-4', isDownloading && 'animate-bounce')} />
212
+ {t('desktopUpdatesDownloadNow')}
213
+ </Button>
214
+
215
+ <Button variant="secondary" onClick={() => void desktopUpdateManager.applyDownloadedUpdate()} disabled={!canApply}>
216
+ <RotateCw className={cn('mr-2 h-4 w-4', isApplying && 'animate-spin')} />
217
+ {t('desktopUpdatesApplyNow')}
218
+ </Button>
219
+
220
+ {snapshot.releaseNotesUrl ? (
221
+ <Button variant="ghost" onClick={() => window.open(snapshot.releaseNotesUrl ?? '', '_blank', 'noopener,noreferrer')}>
222
+ <ExternalLink className="mr-2 h-4 w-4" />
223
+ {t('desktopUpdatesReleaseNotes')}
224
+ </Button>
225
+ ) : null}
226
+ </CardContent>
227
+ </Card>
228
+ </PageLayout>
229
+ );
230
+ }
@@ -1,7 +1,7 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
3
3
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
4
- import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield, Wrench, Wifi, Bot } from 'lucide-react';
4
+ import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield, Wrench, Wifi, Bot, Download } from 'lucide-react';
5
5
  import { NavLink } from 'react-router-dom';
6
6
  import { useDocBrowser } from '@/components/doc-browser';
7
7
  import { BrandHeader } from '@/components/common/BrandHeader';
@@ -105,6 +105,11 @@ export function Sidebar({ mode }: SidebarProps) {
105
105
  label: t('runtime'),
106
106
  icon: GitBranch,
107
107
  },
108
+ {
109
+ target: '/updates',
110
+ label: t('updates'),
111
+ icon: Download,
112
+ },
108
113
  {
109
114
  target: '/remote',
110
115
  label: t('remote'),
@@ -109,6 +109,7 @@ describe('Sidebar', () => {
109
109
  'Plugins',
110
110
  'MCP',
111
111
  'Routing & Runtime',
112
+ 'Updates',
112
113
  'Remote Access',
113
114
  'Security',
114
115
  'Sessions',
@@ -0,0 +1,36 @@
1
+ export type DesktopUpdateStatus =
2
+ | 'idle'
3
+ | 'checking'
4
+ | 'update-available'
5
+ | 'downloading'
6
+ | 'downloaded'
7
+ | 'up-to-date'
8
+ | 'failed';
9
+
10
+ export type DesktopUpdatePreferences = {
11
+ automaticChecks: boolean;
12
+ autoDownload: boolean;
13
+ };
14
+
15
+ export type DesktopUpdateSnapshot = {
16
+ status: DesktopUpdateStatus;
17
+ launcherVersion: string;
18
+ currentVersion: string | null;
19
+ availableVersion: string | null;
20
+ downloadedVersion: string | null;
21
+ releaseNotesUrl: string | null;
22
+ lastCheckedAt: string | null;
23
+ errorMessage: string | null;
24
+ preferences: DesktopUpdatePreferences;
25
+ };
26
+
27
+ export type NextClawDesktopBridge = {
28
+ platform: string;
29
+ version: string;
30
+ getUpdateState: () => Promise<DesktopUpdateSnapshot>;
31
+ checkForUpdates: () => Promise<DesktopUpdateSnapshot>;
32
+ downloadUpdate: () => Promise<DesktopUpdateSnapshot>;
33
+ applyDownloadedUpdate: () => Promise<DesktopUpdateSnapshot>;
34
+ updatePreferences: (preferences: Partial<DesktopUpdatePreferences>) => Promise<DesktopUpdateSnapshot>;
35
+ onUpdateStateChanged: (listener: (snapshot: DesktopUpdateSnapshot) => void) => () => void;
36
+ };
@@ -0,0 +1,163 @@
1
+ import type {
2
+ DesktopUpdatePreferences,
3
+ DesktopUpdateSnapshot,
4
+ NextClawDesktopBridge
5
+ } from '@/desktop/desktop-update.types';
6
+ import { useDesktopUpdateStore } from '@/desktop/stores/desktop-update.store';
7
+ import { t } from '@/lib/i18n';
8
+ import { toast } from 'sonner';
9
+
10
+ type DesktopUpdateBusyAction = 'checking' | 'downloading' | 'applying' | 'saving-preferences';
11
+
12
+ export class DesktopUpdateManager {
13
+ private unsubscribe: (() => void) | null = null;
14
+
15
+ start = async () => {
16
+ const desktopApi = this.getDesktopApi();
17
+ if (!desktopApi) {
18
+ useDesktopUpdateStore.setState({
19
+ supported: false,
20
+ initialized: true,
21
+ snapshot: null
22
+ });
23
+ return;
24
+ }
25
+
26
+ if (!this.unsubscribe) {
27
+ this.unsubscribe = desktopApi.onUpdateStateChanged((snapshot) => {
28
+ useDesktopUpdateStore.setState({
29
+ supported: true,
30
+ initialized: true,
31
+ snapshot
32
+ });
33
+ });
34
+ }
35
+
36
+ useDesktopUpdateStore.setState({
37
+ supported: true,
38
+ initialized: false
39
+ });
40
+
41
+ try {
42
+ const snapshot = await desktopApi.getUpdateState();
43
+ useDesktopUpdateStore.setState({
44
+ supported: true,
45
+ initialized: true,
46
+ snapshot
47
+ });
48
+ } catch (error) {
49
+ useDesktopUpdateStore.setState({
50
+ supported: true,
51
+ initialized: true
52
+ });
53
+ toast.error(`${t('desktopUpdatesLoadFailed')}: ${this.getErrorMessage(error)}`);
54
+ }
55
+ };
56
+
57
+ stop = () => {
58
+ this.unsubscribe?.();
59
+ this.unsubscribe = null;
60
+ };
61
+
62
+ checkForUpdates = async () => {
63
+ let snapshot: DesktopUpdateSnapshot;
64
+ try {
65
+ snapshot = await this.runSnapshotCommand('checking', t('desktopUpdatesCheckFailed'), async (desktopApi) => {
66
+ return await desktopApi.checkForUpdates();
67
+ });
68
+ } catch {
69
+ return;
70
+ }
71
+
72
+ if (snapshot.status === 'up-to-date') {
73
+ toast.success(t('desktopUpdatesAlreadyLatest'));
74
+ return;
75
+ }
76
+ if (snapshot.status === 'update-available') {
77
+ toast.success(
78
+ t('desktopUpdatesAvailable').replace('{version}', snapshot.availableVersion ?? t('desktopUpdatesUnknownVersion'))
79
+ );
80
+ return;
81
+ }
82
+ if (snapshot.status === 'downloaded') {
83
+ toast.success(t('desktopUpdatesReadyToApply'));
84
+ return;
85
+ }
86
+ if (snapshot.status === 'failed' && snapshot.errorMessage) {
87
+ toast.error(snapshot.errorMessage);
88
+ }
89
+ };
90
+
91
+ downloadUpdate = async () => {
92
+ let snapshot: DesktopUpdateSnapshot;
93
+ try {
94
+ snapshot = await this.runSnapshotCommand('downloading', t('desktopUpdatesDownloadFailed'), async (desktopApi) => {
95
+ return await desktopApi.downloadUpdate();
96
+ });
97
+ } catch {
98
+ return;
99
+ }
100
+
101
+ if (snapshot.status === 'downloaded') {
102
+ toast.success(t('desktopUpdatesReadyToApply'));
103
+ }
104
+ };
105
+
106
+ applyDownloadedUpdate = async () => {
107
+ try {
108
+ await this.runSnapshotCommand('applying', t('desktopUpdatesApplyFailed'), async (desktopApi) => {
109
+ return await desktopApi.applyDownloadedUpdate();
110
+ });
111
+ } catch {
112
+ return;
113
+ }
114
+ };
115
+
116
+ updatePreferences = async (preferences: Partial<DesktopUpdatePreferences>) => {
117
+ try {
118
+ await this.runSnapshotCommand(
119
+ 'saving-preferences',
120
+ t('desktopUpdatesPreferencesFailed'),
121
+ async (desktopApi) => await desktopApi.updatePreferences(preferences)
122
+ );
123
+ } catch {
124
+ return;
125
+ }
126
+ };
127
+
128
+ private runSnapshotCommand = async (
129
+ busyAction: DesktopUpdateBusyAction,
130
+ fallbackMessage: string,
131
+ job: (desktopApi: NextClawDesktopBridge) => Promise<DesktopUpdateSnapshot>
132
+ ): Promise<DesktopUpdateSnapshot> => {
133
+ const desktopApi = this.getDesktopApi();
134
+ if (!desktopApi) {
135
+ throw new Error(t('desktopUpdatesDesktopOnlyDescription'));
136
+ }
137
+
138
+ useDesktopUpdateStore.setState({ busyAction });
139
+ try {
140
+ const snapshot = await job(desktopApi);
141
+ useDesktopUpdateStore.setState({ snapshot });
142
+ return snapshot;
143
+ } catch (error) {
144
+ toast.error(`${fallbackMessage}: ${this.getErrorMessage(error)}`);
145
+ throw error;
146
+ } finally {
147
+ useDesktopUpdateStore.setState({ busyAction: null });
148
+ }
149
+ };
150
+
151
+ private getDesktopApi = (): NextClawDesktopBridge | null => {
152
+ if (typeof window === 'undefined') {
153
+ return null;
154
+ }
155
+ return window.nextclawDesktop ?? null;
156
+ };
157
+
158
+ private getErrorMessage = (error: unknown): string => {
159
+ return error instanceof Error ? error.message : t('error');
160
+ };
161
+ }
162
+
163
+ export const desktopUpdateManager = new DesktopUpdateManager();