@nextclaw/ui 0.15.26 → 0.15.28

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 (125) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/assets/{api-CK3Fro7z.js → api-a_GaOFc7.js} +4 -4
  3. package/dist/assets/{appearance-settings-page-zqwWO8Dy.js → appearance-settings-page-KEYKkxHu.js} +1 -1
  4. package/dist/assets/{channels-list-page-CVCZBlCL.js → channels-list-page-eXLXPgKT.js} +2 -2
  5. package/dist/assets/{chat-page-D4wehRTz.js → chat-page-jfImLdsq.js} +1 -1
  6. package/dist/assets/{config-split-page-CceNFvlY.js → config-split-page-ps1Aod3g.js} +1 -1
  7. package/dist/assets/{confirm-dialog-CWeNYWZY.js → confirm-dialog-DJSUNG33.js} +2 -2
  8. package/dist/assets/desktop-update-config-C20U71k1.js +1 -0
  9. package/dist/assets/{dist-D9xtGgdL.js → dist-BkTYNzm_.js} +1 -1
  10. package/dist/assets/{dist-AYxkFx2B.js → dist-DkR7_Dpl.js} +1 -1
  11. package/dist/assets/doc-browser-DahzJEWj.js +1 -0
  12. package/dist/assets/doc-browser-Q67TOGVS.js +1 -0
  13. package/dist/assets/{doc-browser-context-Cb7Z0j90.js → doc-browser-context-BQim8SqD.js} +1 -1
  14. package/dist/assets/form-actions-B2-bpFie.js +1 -0
  15. package/dist/assets/index-BP0Hd2jI.js +109 -0
  16. package/dist/assets/index-DSLK7zgR.css +1 -0
  17. package/dist/assets/loader-circle-DyXZU92l.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-B0dBE5CV.js +1 -0
  19. package/dist/assets/mcp-marketplace-page-w8panjZG.js +9 -0
  20. package/dist/assets/{model-config-page-DfOLyQqR.js → model-config-page-CJCnaKOh.js} +1 -1
  21. package/dist/assets/{navigation-link-MiiyoIjH.js → navigation-link-mpHFXwRr.js} +2 -2
  22. package/dist/assets/plus-Dhb4KuMH.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-DXzjr5l0.js → provider-scoped-model-input-Dqu-ZoWG.js} +1 -1
  24. package/dist/assets/{providers-config-page-CaO8WfZH.js → providers-config-page-C6fRN2rJ.js} +1 -1
  25. package/dist/assets/react-BlXwvOLo.js +8 -0
  26. package/dist/assets/{refresh-cw-a2VVeeAF.js → refresh-cw-JFuyDj_P.js} +1 -1
  27. package/dist/assets/remote-Xyv2c_W9.js +1 -0
  28. package/dist/assets/rotate-cw-DjbbiIW_.js +1 -0
  29. package/dist/assets/{runtime-config-page-BuA4A02z.js → runtime-config-page-CtypMgLd.js} +1 -1
  30. package/dist/assets/{save-CGm23Yww.js → save-BL8P8CLX.js} +1 -1
  31. package/dist/assets/{search-CH2t--sH.js → search-bZL75Pgc.js} +1 -1
  32. package/dist/assets/{search-config-page-BAmYqoAz.js → search-config-page-DhSFjYj4.js} +1 -1
  33. package/dist/assets/{secrets-config-page-BlPOuql_.js → secrets-config-page-BLGcyb1D.js} +2 -2
  34. package/dist/assets/security-config-DmJw9QZA.js +1 -0
  35. package/dist/assets/{select-DnxxDpcV.js → select-rUZ8nIZu.js} +2 -2
  36. package/dist/assets/{settings-2-agnMYc8a.js → settings-2-BI4SCkxQ.js} +1 -1
  37. package/dist/assets/skeleton-CVmEPG43.js +1 -0
  38. package/dist/assets/{tag-chip-CPOc8b2N.js → tag-chip-BdI_yIOA.js} +1 -1
  39. package/dist/assets/x-BcEayhV6.js +1 -0
  40. package/dist/index.html +19 -19
  41. package/package.json +6 -6
  42. package/src/app/components/layout/app-layout.tsx +38 -2
  43. package/src/app/hooks/__tests__/use-app-event-consumers.test.ts +20 -0
  44. package/src/app/hooks/use-app-event-consumers.ts +21 -0
  45. package/src/app/presenters/app.presenter.ts +6 -0
  46. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +116 -14
  47. package/src/features/apps/components/__tests__/apps-panel.test.tsx +58 -0
  48. package/src/features/apps/components/app-artwork.tsx +49 -0
  49. package/src/features/apps/components/app-marketplace-catalog.tsx +332 -0
  50. package/src/features/apps/components/app-marketplace-cover.tsx +64 -0
  51. package/src/features/apps/components/app-marketplace-detail.tsx +161 -0
  52. package/src/features/apps/components/app-marketplace-dialog.tsx +107 -217
  53. package/src/features/apps/components/app-marketplace-operation.tsx +84 -0
  54. package/src/features/apps/components/app-package-card.tsx +201 -52
  55. package/src/features/apps/components/app-packages-panel.tsx +120 -42
  56. package/src/features/apps/components/apps-panel.tsx +28 -27
  57. package/src/features/apps/hooks/use-app-marketplace.test.ts +144 -0
  58. package/src/features/apps/hooks/use-app-marketplace.ts +55 -85
  59. package/src/features/apps/hooks/use-app-packages.ts +59 -4
  60. package/src/features/apps/index.ts +1 -0
  61. package/src/features/apps/managers/__tests__/app-package-operation-settlement.manager.test.ts +81 -0
  62. package/src/features/apps/managers/app-package-operation-settlement.manager.ts +80 -0
  63. package/src/features/apps/services/app-marketplace.service.ts +223 -0
  64. package/src/features/apps/types/app-marketplace.types.ts +75 -0
  65. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +64 -3
  66. package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +24 -14
  67. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +53 -0
  68. package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +18 -0
  69. package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +4 -1
  70. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +126 -32
  71. package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +12 -2
  72. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +31 -0
  73. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
  74. package/src/features/chat/features/input/utils/chat-inline-token-composer.utils.ts +14 -0
  75. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +25 -0
  76. package/src/features/chat/features/message/hooks/use-chat-inline-token-actions.ts +8 -1
  77. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +77 -20
  78. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +56 -50
  79. package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +33 -11
  80. package/src/features/chat/features/workspace/components/project-files/workspace-project-tree-view.tsx +93 -0
  81. package/src/features/chat/features/workspace/stores/__tests__/workspace-project-tree.store.test.ts +38 -0
  82. package/src/features/chat/features/workspace/stores/workspace-project-tree.store.ts +160 -0
  83. package/src/features/chat/index.ts +1 -0
  84. package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +29 -0
  85. package/src/features/chat/managers/chat-composer-intent.manager.ts +29 -2
  86. package/src/features/chat/presenters/chat.presenter.ts +2 -2
  87. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +1 -1
  88. package/src/features/panel-apps/components/panel-apps-list.tsx +1 -6
  89. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +34 -0
  90. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +29 -0
  91. package/src/features/service-apps/components/service-apps-panel.tsx +3 -17
  92. package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -0
  93. package/src/platforms/mobile/components/mobile-app-shell.tsx +9 -1
  94. package/src/shared/components/actions/__tests__/navigation-link.test.tsx +12 -0
  95. package/src/shared/components/actions/navigation-link.tsx +6 -3
  96. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +34 -0
  97. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +5 -0
  98. package/src/shared/components/doc-browser/doc-browser.tsx +11 -1
  99. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +13 -0
  100. package/src/shared/components/ui/tabs.tsx +24 -1
  101. package/src/shared/hooks/use-server-path-watch.ts +42 -0
  102. package/src/shared/lib/api/server-path/server-path.types.ts +10 -0
  103. package/src/shared/lib/api/types.ts +3 -0
  104. package/src/shared/lib/api/utils/server-path.utils.ts +10 -0
  105. package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
  106. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +49 -7
  107. package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
  108. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +49 -7
  109. package/vite.config.ts +32 -1
  110. package/dist/assets/desktop-update-config-Dirc3bpt.js +0 -1
  111. package/dist/assets/doc-browser-4Sud6dWw.js +0 -1
  112. package/dist/assets/doc-browser-D4x_yH5F.js +0 -1
  113. package/dist/assets/form-actions-B2q4jgjE.js +0 -1
  114. package/dist/assets/index-CdyTsSBu.css +0 -1
  115. package/dist/assets/index-Das_bw2h.js +0 -107
  116. package/dist/assets/loader-circle-CpmBdaon.js +0 -1
  117. package/dist/assets/mcp-marketplace-page-CdOY3Sop.js +0 -1
  118. package/dist/assets/mcp-marketplace-page-DvjrfqEt.js +0 -9
  119. package/dist/assets/plus-C8FkjpSZ.js +0 -1
  120. package/dist/assets/react-C8XFFvuz.js +0 -8
  121. package/dist/assets/remote-BJHXa4mB.js +0 -1
  122. package/dist/assets/rotate-cw-DJ7UF3F2.js +0 -1
  123. package/dist/assets/security-config-CbDiY2xr.js +0 -1
  124. package/dist/assets/skeleton-XpqeWL62.js +0 -1
  125. package/dist/assets/x-odTHAtLZ.js +0 -1
@@ -1,9 +1,11 @@
1
1
  import { useState } from 'react';
2
- import type { AppPackageView } from '@nextclaw/client-sdk';
2
+ import type { AppPackageOperationView, AppPackageView } from '@nextclaw/client-sdk';
3
3
  import {
4
+ AlertCircle,
4
5
  AppWindow,
5
6
  Bookmark,
6
7
  CalendarDays,
8
+ Check,
7
9
  CheckSquare2,
8
10
  ChevronRight,
9
11
  MoreHorizontal,
@@ -12,12 +14,22 @@ import {
12
14
  RotateCcw,
13
15
  Server,
14
16
  Trash2,
17
+ LoaderCircle,
15
18
  type LucideIcon,
16
19
  } from 'lucide-react';
20
+ import { AppArtwork } from '@/features/apps/components/app-artwork';
21
+ import { isAppPackageOperationActive } from '@/features/apps/hooks/use-app-packages';
17
22
  import type { PanelAppEntryView } from '@/shared/lib/api';
18
23
  import { Button } from '@/shared/components/ui/button';
19
24
  import { Card } from '@/shared/components/ui/card';
20
- import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
25
+ import {
26
+ Dialog,
27
+ DialogContent,
28
+ DialogDescription,
29
+ DialogFooter,
30
+ DialogHeader,
31
+ DialogTitle,
32
+ } from '@/shared/components/ui/dialog';
21
33
  import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
22
34
  import { getLanguage, t } from '@/shared/lib/i18n';
23
35
  import { cn } from '@/shared/lib/utils';
@@ -25,6 +37,7 @@ import { cn } from '@/shared/lib/utils';
25
37
  export function AppPackageCard({
26
38
  appPackage,
27
39
  isPending,
40
+ operation,
28
41
  onDisable,
29
42
  onEnable,
30
43
  onOpenPanelApp,
@@ -35,33 +48,35 @@ export function AppPackageCard({
35
48
  }: {
36
49
  appPackage: AppPackageView;
37
50
  isPending: boolean;
51
+ operation?: AppPackageOperationView;
38
52
  onDisable: () => void;
39
53
  onEnable: () => void;
40
54
  onOpenPanelApp: (entry: PanelAppEntryView) => void;
41
55
  onRollback: (version: string) => void;
42
- onUninstall: () => void;
56
+ onUninstall: (purgeData: boolean) => void;
43
57
  onUpdate: () => void;
44
58
  panelApps: PanelAppEntryView[];
45
59
  }) {
46
60
  const [menuOpen, setMenuOpen] = useState(false);
47
61
  const [uninstallOpen, setUninstallOpen] = useState(false);
62
+ const [purgeData, setPurgeData] = useState(false);
48
63
  const panelComponents = appPackage.components.filter((component) => component.kind === 'panel');
49
64
  const serviceCount = appPackage.components.length - panelComponents.length;
50
65
  const rollbackVersions = appPackage.installedVersions.filter(
51
66
  (version) => version !== appPackage.activeVersion,
52
67
  );
53
- const displayName = readLocalizedText(appPackage.name, appPackage.nameI18n);
68
+ const displayName = readLocalizedText(appPackage.name, appPackage.nameI18n) ?? appPackage.id;
54
69
  const displayDescription = readLocalizedText(
55
70
  appPackage.description,
56
71
  appPackage.descriptionI18n,
57
72
  );
73
+ const operationActive = operation ? isAppPackageOperationActive(operation.status) : false;
74
+ const pending = isPending || operationActive;
58
75
 
59
76
  return (
60
- <Card surface="flat" hover={false} className="overflow-hidden">
61
- <div className="flex items-start gap-3 px-3.5 pb-3 pt-3.5">
62
- <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
63
- <AppWindow className="h-5 w-5" />
64
- </div>
77
+ <Card surface="flat" hover={false} className="overflow-hidden border-border/60 shadow-[0_1px_2px_rgba(0,0,0,0.025)]">
78
+ <div className="flex items-start gap-3.5 px-4 pb-3.5 pt-4">
79
+ <AppArtwork icon={appPackage.icon} name={displayName} />
65
80
  <div className="min-w-0 flex-1">
66
81
  <div className="flex min-w-0 items-center gap-2">
67
82
  <h2 className="truncate text-sm font-semibold text-foreground">{displayName}</h2>
@@ -95,39 +110,37 @@ export function AppPackageCard({
95
110
  type="button"
96
111
  size="sm"
97
112
  variant={appPackage.enabled ? 'outline' : 'default'}
98
- disabled={isPending}
113
+ disabled={pending}
99
114
  onClick={appPackage.enabled ? onDisable : onEnable}
100
115
  >
101
- {isPending
102
- ? t('appPackagesWorking')
103
- : appPackage.enabled
104
- ? t('appPackagesDisable')
105
- : t('appPackagesEnable')}
116
+ {renderPrimaryActionLabel({
117
+ enabled: appPackage.enabled,
118
+ isPending,
119
+ operation,
120
+ operationActive,
121
+ })}
106
122
  </Button>
107
- {(!appPackage.builtIn || rollbackVersions.length > 0) ? (
108
- <Popover open={menuOpen} onOpenChange={setMenuOpen}>
123
+ <Popover open={menuOpen} onOpenChange={setMenuOpen}>
109
124
  <PopoverTrigger asChild>
110
125
  <button
111
126
  type="button"
112
127
  aria-label={t('appPackagesMoreActions')}
113
128
  title={t('appPackagesMoreActions')}
114
- disabled={isPending}
129
+ disabled={pending}
115
130
  className="rounded-full p-2 text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border disabled:opacity-50"
116
131
  >
117
132
  <MoreHorizontal className="h-4 w-4" />
118
133
  </button>
119
134
  </PopoverTrigger>
120
135
  <PopoverContent align="end" className="w-52 rounded-xl p-1.5">
121
- {!appPackage.builtIn ? (
122
- <AppPackageMenuItem
123
- icon={RefreshCw}
124
- label={t('appPackagesCheckUpdate')}
125
- onClick={() => {
126
- setMenuOpen(false);
127
- onUpdate();
128
- }}
129
- />
130
- ) : null}
136
+ <AppPackageMenuItem
137
+ icon={RefreshCw}
138
+ label={t('appPackagesCheckUpdate')}
139
+ onClick={() => {
140
+ setMenuOpen(false);
141
+ onUpdate();
142
+ }}
143
+ />
131
144
  {rollbackVersions.map((version) => (
132
145
  <AppPackageMenuItem
133
146
  key={version}
@@ -139,23 +152,24 @@ export function AppPackageCard({
139
152
  }}
140
153
  />
141
154
  ))}
142
- {!appPackage.builtIn ? (
143
- <AppPackageMenuItem
144
- destructive
145
- icon={Trash2}
146
- label={t('appPackagesUninstall')}
147
- onClick={() => {
148
- setMenuOpen(false);
149
- setUninstallOpen(true);
150
- }}
151
- />
152
- ) : null}
155
+ <AppPackageMenuItem
156
+ destructive
157
+ icon={Trash2}
158
+ label={t('appPackagesUninstall')}
159
+ onClick={() => {
160
+ setMenuOpen(false);
161
+ setUninstallOpen(true);
162
+ }}
163
+ />
153
164
  </PopoverContent>
154
165
  </Popover>
155
- ) : null}
156
166
  </div>
157
167
  </div>
158
168
 
169
+ {operation && (operationActive || operation.status === 'failed' || operation.status === 'interrupted') ? (
170
+ <PackageOperationStatus operation={operation} />
171
+ ) : null}
172
+
159
173
  {panelComponents.length > 0 ? (
160
174
  <div className="border-t border-border/60 bg-muted/20 p-2">
161
175
  <div className="grid grid-cols-2 gap-1.5">
@@ -168,7 +182,7 @@ export function AppPackageCard({
168
182
  <button
169
183
  key={component.id}
170
184
  type="button"
171
- disabled={!appPackage.enabled || !panelApp || isPending}
185
+ disabled={!appPackage.enabled || !panelApp || pending}
172
186
  onClick={() => panelApp && onOpenPanelApp({
173
187
  ...panelApp,
174
188
  title: componentTitle,
@@ -196,20 +210,108 @@ export function AppPackageCard({
196
210
  </div>
197
211
  ) : null}
198
212
 
199
- <ConfirmDialog
200
- open={uninstallOpen}
201
- onOpenChange={setUninstallOpen}
202
- title={t('appPackagesUninstallTitle')}
203
- description={t('appPackagesUninstallDescription')}
204
- confirmLabel={t('appPackagesUninstall')}
205
- variant="destructive"
206
- onConfirm={onUninstall}
207
- onCancel={() => undefined}
208
- />
213
+ <Dialog open={uninstallOpen} onOpenChange={setUninstallOpen}>
214
+ <DialogContent className="max-w-md [&>:last-child]:hidden">
215
+ <DialogHeader>
216
+ <DialogTitle>{t('appPackagesUninstallTitle')}</DialogTitle>
217
+ <DialogDescription>{t('appPackagesUninstallDescription')}</DialogDescription>
218
+ </DialogHeader>
219
+ <div className="mt-4 grid gap-2">
220
+ <DataChoice
221
+ checked={!purgeData}
222
+ description={t('appPackagesKeepDataDescription')}
223
+ label={t('appPackagesKeepData')}
224
+ onClick={() => setPurgeData(false)}
225
+ />
226
+ <DataChoice
227
+ checked={purgeData}
228
+ destructive
229
+ description={t('appPackagesDeleteDataDescription')}
230
+ label={t('appPackagesDeleteData')}
231
+ onClick={() => setPurgeData(true)}
232
+ />
233
+ </div>
234
+ <DialogFooter className="mt-5 gap-2 sm:gap-0">
235
+ <Button type="button" variant="outline" onClick={() => setUninstallOpen(false)}>
236
+ {t('cancel')}
237
+ </Button>
238
+ <Button
239
+ type="button"
240
+ variant="destructive"
241
+ onClick={() => {
242
+ onUninstall(purgeData);
243
+ setUninstallOpen(false);
244
+ }}
245
+ >
246
+ {t('appPackagesUninstall')}
247
+ </Button>
248
+ </DialogFooter>
249
+ </DialogContent>
250
+ </Dialog>
209
251
  </Card>
210
252
  );
211
253
  }
212
254
 
255
+ function renderPrimaryActionLabel({
256
+ enabled,
257
+ isPending,
258
+ operation,
259
+ operationActive,
260
+ }: {
261
+ enabled: boolean;
262
+ isPending: boolean;
263
+ operation?: AppPackageOperationView;
264
+ operationActive: boolean;
265
+ }) {
266
+ if (operationActive) {
267
+ return (
268
+ <>
269
+ <LoaderCircle className="mr-1.5 h-3.5 w-3.5 animate-spin" />
270
+ {operation ? operationLabel(operation) : t('appPackagesWorking')}
271
+ </>
272
+ );
273
+ }
274
+ if (isPending) return t('appPackagesWorking');
275
+ return enabled ? t('appPackagesDisable') : t('appPackagesEnable');
276
+ }
277
+
278
+ function DataChoice({
279
+ checked,
280
+ description,
281
+ destructive = false,
282
+ label,
283
+ onClick,
284
+ }: {
285
+ checked: boolean;
286
+ description: string;
287
+ destructive?: boolean;
288
+ label: string;
289
+ onClick: () => void;
290
+ }) {
291
+ return (
292
+ <button
293
+ type="button"
294
+ aria-pressed={checked}
295
+ onClick={onClick}
296
+ className={cn(
297
+ 'flex items-start gap-3 rounded-xl border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border',
298
+ checked ? 'border-foreground/25 bg-muted/55' : 'border-border/60 hover:bg-muted/35',
299
+ )}
300
+ >
301
+ <span className={cn(
302
+ 'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
303
+ checked ? 'border-foreground bg-foreground text-background' : 'border-border',
304
+ )}>
305
+ {checked ? <Check className="h-2.5 w-2.5" /> : null}
306
+ </span>
307
+ <span>
308
+ <span className={cn('block text-sm font-medium', destructive ? 'text-destructive' : 'text-foreground')}>{label}</span>
309
+ <span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{description}</span>
310
+ </span>
311
+ </button>
312
+ );
313
+ }
314
+
213
315
  function AppPackageMenuItem({
214
316
  destructive = false,
215
317
  icon: Icon,
@@ -245,12 +347,59 @@ function ComponentIcon({
245
347
  fallback: LucideIcon;
246
348
  icon?: string;
247
349
  }) {
248
- if (icon && !icon.startsWith('/') && !icon.includes('://')) {
350
+ if (icon && (
351
+ icon.startsWith('data:') ||
352
+ icon.startsWith('/') ||
353
+ icon.startsWith('http://') ||
354
+ icon.startsWith('https://')
355
+ )) {
356
+ return <img src={icon} alt="" className="h-4 w-4 rounded object-cover" />;
357
+ }
358
+ if (icon) {
249
359
  return <span className="text-sm leading-none">{icon}</span>;
250
360
  }
251
361
  return <Fallback className="h-3.5 w-3.5" />;
252
362
  }
253
363
 
364
+ function PackageOperationStatus({ operation }: { operation: AppPackageOperationView }) {
365
+ const failed = operation.status === 'failed' || operation.status === 'interrupted';
366
+ const progress = Math.max(4, Math.round((operation.completedSteps / operation.totalSteps) * 100));
367
+ return (
368
+ <div className="border-t border-border/50 px-4 py-2.5" role={failed ? 'alert' : 'status'} aria-live="polite">
369
+ <div className="flex items-center gap-2 text-[11px]">
370
+ {failed ? <AlertCircle className="h-3.5 w-3.5 text-destructive" /> : <LoaderCircle className="h-3.5 w-3.5 animate-spin text-primary" />}
371
+ <span className={failed ? 'text-destructive' : 'text-muted-foreground'}>
372
+ {failed ? operation.error ?? t('appPackagesActionFailed') : operationLabel(operation)}
373
+ </span>
374
+ </div>
375
+ <div className="mt-2 h-1 overflow-hidden rounded-full bg-muted">
376
+ <div
377
+ className={cn('h-full rounded-full transition-[width] duration-300', failed ? 'bg-destructive' : 'bg-primary')}
378
+ style={{ width: failed ? '100%' : `${progress}%` }}
379
+ />
380
+ </div>
381
+ </div>
382
+ );
383
+ }
384
+
385
+ function operationLabel(operation: AppPackageOperationView): string {
386
+ switch (operation.status) {
387
+ case 'queued': return t('appPackagesPreparing');
388
+ case 'resolving': return t('appPackagesResolving');
389
+ case 'downloading': return t('appPackagesDownloading');
390
+ case 'verifying': return t('appPackagesVerifying');
391
+ case 'installing': return operation.action === 'rollback'
392
+ ? `${t('appPackagesSwitchingVersion')} ${operation.targetVersion ? `v${operation.targetVersion}` : ''}`.trim()
393
+ : operation.action === 'uninstall'
394
+ ? t('appPackagesUninstalling')
395
+ : t('appPackagesInstalling');
396
+ case 'finalizing': return t('appPackagesFinalizing');
397
+ case 'succeeded': return t('appPackagesCompleted');
398
+ case 'failed':
399
+ case 'interrupted': return t('appPackagesActionFailed');
400
+ }
401
+ }
402
+
254
403
  function resolveComponentIcon(componentId: string): LucideIcon {
255
404
  if (componentId.endsWith('-todos')) return CheckSquare2;
256
405
  if (componentId.endsWith('-notes')) return NotebookText;
@@ -1,13 +1,17 @@
1
- import { useState, type FormEvent, type ReactNode } from 'react';
2
- import type { AppPackageView } from '@nextclaw/client-sdk';
3
- import { Boxes, PackagePlus, RefreshCw, ShieldCheck, Store } from 'lucide-react';
1
+ import { useMemo, useState, type FormEvent } from 'react';
2
+ import type { AppPackageOperationView, AppPackageView } from '@nextclaw/client-sdk';
3
+ import { LoaderCircle, PackagePlus, RefreshCw, ShieldCheck, Store } from 'lucide-react';
4
+ import { AppArtwork } from '@/features/apps/components/app-artwork';
4
5
  import type { PanelAppEntryView } from '@/shared/lib/api';
5
6
  import { useAppPresenter } from '@/app/components/app-presenter-provider';
6
7
  import { AppMarketplaceDialog } from '@/features/apps/components/app-marketplace-dialog';
7
8
  import { AppPackageCard } from '@/features/apps/components/app-package-card';
8
9
  import {
9
10
  useAppPackageMutation,
11
+ useAppPackageOperationSettlement,
12
+ useAppPackageOperations,
10
13
  useAppPackages,
14
+ isAppPackageOperationActive,
11
15
  type AppPackageMutationInput,
12
16
  } from '@/features/apps/hooks/use-app-packages';
13
17
  import {
@@ -29,13 +33,12 @@ import { Skeleton } from '@/shared/components/ui/skeleton';
29
33
  import { t } from '@/shared/lib/i18n';
30
34
 
31
35
  export function AppPackagesPanel({
32
- headerContent,
33
36
  onOpenPanelApp,
34
37
  }: {
35
- headerContent?: ReactNode;
36
38
  onOpenPanelApp: (entry: PanelAppEntryView) => void;
37
39
  }) {
38
40
  const appPackages = useAppPackages();
41
+ const appPackageOperations = useAppPackageOperations();
39
42
  const panelApps = usePanelApps();
40
43
  const lifecycle = useAppPackageMutation();
41
44
  const recordOpened = useRecordPanelAppOpened();
@@ -45,6 +48,19 @@ export function AppPackagesPanel({
45
48
  const [installSource, setInstallSource] = useState('');
46
49
  const [marketplaceOpen, setMarketplaceOpen] = useState(false);
47
50
 
51
+ const operations = useMemo(
52
+ () => appPackageOperations.data?.entries ?? [],
53
+ [appPackageOperations.data?.entries],
54
+ );
55
+
56
+ useAppPackageOperationSettlement({
57
+ isLoaded: Boolean(appPackageOperations.data),
58
+ operations,
59
+ refetchPackages: appPackages.refetch,
60
+ refetchPanels: panelApps.refetch,
61
+ settlementManager: presenter.appPackageOperationSettlementManager,
62
+ });
63
+
48
64
  const runMutation = (input: AppPackageMutationInput, onSuccess?: () => void) => {
49
65
  lifecycle.reset();
50
66
  lifecycle.mutate(input, { onSuccess });
@@ -83,14 +99,13 @@ export function AppPackagesPanel({
83
99
 
84
100
  const refetch = () => {
85
101
  void appPackages.refetch();
102
+ void appPackageOperations.refetch();
86
103
  void panelApps.refetch();
87
104
  };
88
105
 
89
106
  return (
90
107
  <div className="@container flex h-full min-h-0 flex-col bg-card text-card-foreground">
91
- <div className="flex flex-col gap-2 border-b border-border/70 px-4 py-3 @[42rem]:flex-row @[42rem]:items-center @[42rem]:justify-between">
92
- <div className="w-full min-w-0 overflow-x-auto @[42rem]:flex-1">{headerContent}</div>
93
- <div className="ml-auto flex shrink-0 items-center gap-1">
108
+ <div className="flex min-h-12 shrink-0 items-center justify-end gap-1 border-b border-border/70 px-4 py-2">
94
109
  <button
95
110
  type="button"
96
111
  onClick={refetch}
@@ -124,23 +139,15 @@ export function AppPackagesPanel({
124
139
  <PackagePlus className="mr-1.5 h-3.5 w-3.5" />
125
140
  {t('appPackagesInstall')}
126
141
  </Button>
127
- </div>
128
142
  </div>
129
143
 
130
144
  <div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
131
- <div className="mx-auto w-full max-w-2xl p-3 sm:p-4">
132
- <div className="mb-3 rounded-xl bg-gradient-to-br from-primary/[0.09] via-primary/[0.045] to-transparent px-3.5 py-3 ring-1 ring-primary/10">
133
- <div className="flex items-start gap-2.5">
134
- <span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
135
- <Boxes className="h-4 w-4" />
136
- </span>
137
- <div>
138
- <h1 className="text-sm font-semibold text-foreground">{t('appPackagesLibraryTitle')}</h1>
139
- <p className="mt-0.5 text-xs leading-5 text-muted-foreground">
140
- {t('appPackagesLibraryDescription')}
141
- </p>
142
- </div>
143
- </div>
145
+ <div className="mx-auto w-full max-w-3xl p-3 sm:p-5">
146
+ <div className="mb-4 px-1">
147
+ <h1 className="text-base font-semibold tracking-tight text-foreground">{t('appPackagesLibraryTitle')}</h1>
148
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">
149
+ {t('appPackagesLibraryDescription')}
150
+ </p>
144
151
  </div>
145
152
 
146
153
  {lifecycle.isError ? (
@@ -158,6 +165,7 @@ export function AppPackagesPanel({
158
165
  : undefined}
159
166
  packages={appPackages.data?.entries ?? []}
160
167
  panelApps={panelApps.data?.entries ?? []}
168
+ operations={operations}
161
169
  onInstall={() => setInstallOpen(true)}
162
170
  onMutate={runMutation}
163
171
  onOpenPanelApp={(entry) => void openPanelApp(entry)}
@@ -168,17 +176,19 @@ export function AppPackagesPanel({
168
176
  <AppMarketplaceDialog
169
177
  error={lifecycle.error instanceof Error ? lifecycle.error : null}
170
178
  installedPackages={appPackages.data?.entries ?? []}
171
- installingSource={lifecycle.variables?.action === 'install'
179
+ startingSource={lifecycle.variables?.action === 'install'
172
180
  ? lifecycle.variables.source
173
181
  : undefined}
174
- isPending={lifecycle.isPending}
182
+ isStarting={lifecycle.isPending}
183
+ operations={operations}
175
184
  open={marketplaceOpen}
176
185
  onOpenChange={setMarketplaceOpen}
177
186
  onInstall={(source, registryUrl) => runMutation({
178
187
  action: 'install',
179
188
  source,
180
189
  registryUrl,
181
- }, () => setMarketplaceOpen(false))}
190
+ })}
191
+ onUpdate={(appId) => runMutation({ action: 'update', appId })}
182
192
  />
183
193
 
184
194
  <Dialog open={installOpen} onOpenChange={setInstallOpen}>
@@ -231,6 +241,7 @@ function AppPackageLibrary({
231
241
  onOpenPanelApp,
232
242
  packages,
233
243
  panelApps,
244
+ operations,
234
245
  }: {
235
246
  error?: string;
236
247
  isLoading: boolean;
@@ -241,6 +252,7 @@ function AppPackageLibrary({
241
252
  onOpenPanelApp: (entry: PanelAppEntryView) => void;
242
253
  packages: AppPackageView[];
243
254
  panelApps: PanelAppEntryView[];
255
+ operations: AppPackageOperationView[];
244
256
  }) {
245
257
  if (isLoading) {
246
258
  return (
@@ -258,6 +270,13 @@ function AppPackageLibrary({
258
270
  );
259
271
  }
260
272
  if (packages.length === 0) {
273
+ const activeInstalls = operations.filter((entry) =>
274
+ entry.action === 'install' && isAppPackageOperationActive(entry.status));
275
+ if (activeInstalls.length > 0) {
276
+ return <div className="space-y-2.5">{activeInstalls.map((operation) => (
277
+ <PendingInstallCard key={operation.id} operation={operation} />
278
+ ))}</div>;
279
+ }
261
280
  return (
262
281
  <div className="flex flex-col items-center rounded-2xl border border-dashed border-border px-6 py-10 text-center">
263
282
  <PackagePlus className="h-7 w-7 text-muted-foreground" />
@@ -273,28 +292,87 @@ function AppPackageLibrary({
273
292
  }
274
293
  return (
275
294
  <div className="space-y-2.5">
276
- {packages.map((appPackage) => (
277
- <AppPackageCard
278
- key={appPackage.id}
279
- appPackage={appPackage}
280
- panelApps={panelApps.filter((entry) => entry.packageId === appPackage.id)}
281
- isPending={isPending && mutationAppId === appPackage.id}
282
- onEnable={() => onMutate({ action: 'enable', appId: appPackage.id })}
283
- onDisable={() => onMutate({ action: 'disable', appId: appPackage.id })}
284
- onUpdate={() => onMutate({ action: 'update', appId: appPackage.id })}
285
- onRollback={(version) => onMutate({
286
- action: 'rollback',
287
- appId: appPackage.id,
288
- version,
289
- })}
290
- onUninstall={() => onMutate({ action: 'uninstall', appId: appPackage.id })}
291
- onOpenPanelApp={onOpenPanelApp}
292
- />
295
+ {operations.filter((entry) =>
296
+ entry.action === 'install' &&
297
+ isAppPackageOperationActive(entry.status) &&
298
+ !packages.some((appPackage) => appPackage.id === entry.appId),
299
+ ).map((operation) => (
300
+ <PendingInstallCard key={operation.id} operation={operation} />
293
301
  ))}
302
+ {packages.map((appPackage) => {
303
+ const latestOperation = operations.find((entry) => entry.appId === appPackage.id);
304
+ const visibleOperation = latestOperation && (
305
+ isAppPackageOperationActive(latestOperation.status) ||
306
+ latestOperation.status === 'failed' ||
307
+ latestOperation.status === 'interrupted'
308
+ )
309
+ ? latestOperation
310
+ : undefined;
311
+
312
+ return (
313
+ <AppPackageCard
314
+ key={appPackage.id}
315
+ appPackage={appPackage}
316
+ panelApps={panelApps.filter((entry) => entry.packageId === appPackage.id)}
317
+ isPending={isPending && mutationAppId === appPackage.id}
318
+ operation={visibleOperation}
319
+ onEnable={() => onMutate({ action: 'enable', appId: appPackage.id })}
320
+ onDisable={() => onMutate({ action: 'disable', appId: appPackage.id })}
321
+ onUpdate={() => onMutate({ action: 'update', appId: appPackage.id })}
322
+ onRollback={(version) => onMutate({
323
+ action: 'rollback',
324
+ appId: appPackage.id,
325
+ version,
326
+ })}
327
+ onUninstall={(purgeData) => onMutate({
328
+ action: 'uninstall',
329
+ appId: appPackage.id,
330
+ purgeData,
331
+ })}
332
+ onOpenPanelApp={onOpenPanelApp}
333
+ />
334
+ );
335
+ })}
294
336
  </div>
295
337
  );
296
338
  }
297
339
 
340
+ function PendingInstallCard({ operation }: { operation: AppPackageOperationView }) {
341
+ const label = operation.appId ?? operation.source ?? t('appPackagesInstall');
342
+ const progress = Math.max(4, Math.round((operation.completedSteps / operation.totalSteps) * 100));
343
+ return (
344
+ <div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-[0_1px_2px_rgba(0,0,0,0.025)]" role="status" aria-live="polite">
345
+ <div className="flex items-center gap-3">
346
+ <AppArtwork name={label} />
347
+ <div className="min-w-0 flex-1">
348
+ <p className="truncate text-sm font-semibold text-foreground">{label}</p>
349
+ <p className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
350
+ <LoaderCircle className="h-3.5 w-3.5 animate-spin" />
351
+ {operationProgressLabel(operation)}
352
+ </p>
353
+ </div>
354
+ </div>
355
+ <div className="ml-14 mt-2 h-1 overflow-hidden rounded-full bg-muted">
356
+ <div className="h-full rounded-full bg-primary transition-[width] duration-300" style={{ width: `${progress}%` }} />
357
+ </div>
358
+ </div>
359
+ );
360
+ }
361
+
362
+ function operationProgressLabel(operation: AppPackageOperationView): string {
363
+ switch (operation.status) {
364
+ case 'queued': return t('appPackagesPreparing');
365
+ case 'resolving': return t('appPackagesResolving');
366
+ case 'downloading': return t('appPackagesDownloading');
367
+ case 'verifying': return t('appPackagesVerifying');
368
+ case 'installing': return t('appPackagesInstalling');
369
+ case 'finalizing': return t('appPackagesFinalizing');
370
+ case 'succeeded': return t('appPackagesCompleted');
371
+ case 'failed':
372
+ case 'interrupted': return operation.error ?? t('appPackagesActionFailed');
373
+ }
374
+ }
375
+
298
376
  function getPackageLoadError(...errors: unknown[]): string | undefined {
299
377
  const error = errors.find((candidate): candidate is Error => candidate instanceof Error);
300
378
  return error?.message || (errors.some(Boolean) ? t('appPackagesLoadFailed') : undefined);