@nextclaw/ui 0.15.26 → 0.15.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/assets/{api-CK3Fro7z.js → api-a_GaOFc7.js} +4 -4
- package/dist/assets/{appearance-settings-page-zqwWO8Dy.js → appearance-settings-page-DlGXLgff.js} +1 -1
- package/dist/assets/{channels-list-page-CVCZBlCL.js → channels-list-page-BEEk4Uc9.js} +2 -2
- package/dist/assets/{chat-page-D4wehRTz.js → chat-page-DUQXXqQm.js} +1 -1
- package/dist/assets/{config-split-page-CceNFvlY.js → config-split-page-ps1Aod3g.js} +1 -1
- package/dist/assets/{confirm-dialog-CWeNYWZY.js → confirm-dialog-DJSUNG33.js} +2 -2
- package/dist/assets/desktop-update-config--zagjHic.js +1 -0
- package/dist/assets/{dist-D9xtGgdL.js → dist-BkTYNzm_.js} +1 -1
- package/dist/assets/{dist-AYxkFx2B.js → dist-DkR7_Dpl.js} +1 -1
- package/dist/assets/doc-browser-DahzJEWj.js +1 -0
- package/dist/assets/doc-browser-Q67TOGVS.js +1 -0
- package/dist/assets/{doc-browser-context-Cb7Z0j90.js → doc-browser-context-BQim8SqD.js} +1 -1
- package/dist/assets/form-actions-B2-bpFie.js +1 -0
- package/dist/assets/index-Ba_o5fu9.js +109 -0
- package/dist/assets/index-DSLK7zgR.css +1 -0
- package/dist/assets/loader-circle-DyXZU92l.js +1 -0
- package/dist/assets/mcp-marketplace-page-B0dBE5CV.js +1 -0
- package/dist/assets/mcp-marketplace-page-w8panjZG.js +9 -0
- package/dist/assets/{model-config-page-DfOLyQqR.js → model-config-page-rrhFaque.js} +1 -1
- package/dist/assets/{navigation-link-MiiyoIjH.js → navigation-link-mpHFXwRr.js} +2 -2
- package/dist/assets/plus-Dhb4KuMH.js +1 -0
- package/dist/assets/{provider-scoped-model-input-DXzjr5l0.js → provider-scoped-model-input-CFsfWEt9.js} +1 -1
- package/dist/assets/{providers-config-page-CaO8WfZH.js → providers-config-page-D-TYFZsF.js} +1 -1
- package/dist/assets/react-BlXwvOLo.js +8 -0
- package/dist/assets/{refresh-cw-a2VVeeAF.js → refresh-cw-JFuyDj_P.js} +1 -1
- package/dist/assets/remote-BwVv_Qq-.js +1 -0
- package/dist/assets/rotate-cw-DjbbiIW_.js +1 -0
- package/dist/assets/{runtime-config-page-BuA4A02z.js → runtime-config-page-Br9j9Vxe.js} +1 -1
- package/dist/assets/{save-CGm23Yww.js → save-BL8P8CLX.js} +1 -1
- package/dist/assets/{search-CH2t--sH.js → search-bZL75Pgc.js} +1 -1
- package/dist/assets/{search-config-page-BAmYqoAz.js → search-config-page-B3fvzMd1.js} +1 -1
- package/dist/assets/{secrets-config-page-BlPOuql_.js → secrets-config-page-ClwTxtXd.js} +2 -2
- package/dist/assets/security-config-DmJw9QZA.js +1 -0
- package/dist/assets/{select-DnxxDpcV.js → select-rUZ8nIZu.js} +2 -2
- package/dist/assets/{settings-2-agnMYc8a.js → settings-2-BI4SCkxQ.js} +1 -1
- package/dist/assets/skeleton-CVmEPG43.js +1 -0
- package/dist/assets/{tag-chip-CPOc8b2N.js → tag-chip-BdI_yIOA.js} +1 -1
- package/dist/assets/x-BcEayhV6.js +1 -0
- package/dist/index.html +19 -19
- package/package.json +7 -7
- package/src/app/components/layout/app-layout.tsx +38 -2
- package/src/app/hooks/__tests__/use-app-event-consumers.test.ts +20 -0
- package/src/app/hooks/use-app-event-consumers.ts +21 -0
- package/src/app/presenters/app.presenter.ts +6 -0
- package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +92 -14
- package/src/features/apps/components/__tests__/apps-panel.test.tsx +58 -0
- package/src/features/apps/components/app-artwork.tsx +49 -0
- package/src/features/apps/components/app-marketplace-catalog.tsx +332 -0
- package/src/features/apps/components/app-marketplace-cover.tsx +64 -0
- package/src/features/apps/components/app-marketplace-detail.tsx +161 -0
- package/src/features/apps/components/app-marketplace-dialog.tsx +107 -217
- package/src/features/apps/components/app-marketplace-operation.tsx +84 -0
- package/src/features/apps/components/app-package-card.tsx +201 -52
- package/src/features/apps/components/app-packages-panel.tsx +98 -26
- package/src/features/apps/components/apps-panel.tsx +28 -27
- package/src/features/apps/hooks/use-app-marketplace.test.ts +144 -0
- package/src/features/apps/hooks/use-app-marketplace.ts +55 -85
- package/src/features/apps/hooks/use-app-packages.ts +59 -4
- package/src/features/apps/index.ts +1 -0
- package/src/features/apps/managers/__tests__/app-package-operation-settlement.manager.test.ts +81 -0
- package/src/features/apps/managers/app-package-operation-settlement.manager.ts +80 -0
- package/src/features/apps/services/app-marketplace.service.ts +223 -0
- package/src/features/apps/types/app-marketplace.types.ts +75 -0
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +64 -3
- package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +24 -14
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +53 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +18 -0
- package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +4 -1
- package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +126 -32
- package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +12 -2
- package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +31 -0
- package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
- package/src/features/chat/features/input/utils/chat-inline-token-composer.utils.ts +14 -0
- package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +25 -0
- package/src/features/chat/features/message/hooks/use-chat-inline-token-actions.ts +8 -1
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +77 -20
- package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +56 -50
- package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +33 -11
- package/src/features/chat/features/workspace/components/project-files/workspace-project-tree-view.tsx +93 -0
- package/src/features/chat/features/workspace/stores/__tests__/workspace-project-tree.store.test.ts +38 -0
- package/src/features/chat/features/workspace/stores/workspace-project-tree.store.ts +160 -0
- package/src/features/chat/index.ts +1 -0
- package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +29 -0
- package/src/features/chat/managers/chat-composer-intent.manager.ts +29 -2
- package/src/features/chat/presenters/chat.presenter.ts +2 -2
- package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +1 -1
- package/src/features/panel-apps/components/panel-apps-list.tsx +1 -6
- package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +34 -0
- package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +29 -0
- package/src/features/service-apps/components/service-apps-panel.tsx +3 -17
- package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -0
- package/src/platforms/mobile/components/mobile-app-shell.tsx +9 -1
- package/src/shared/components/actions/__tests__/navigation-link.test.tsx +12 -0
- package/src/shared/components/actions/navigation-link.tsx +6 -3
- package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +34 -0
- package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +5 -0
- package/src/shared/components/doc-browser/doc-browser.tsx +11 -1
- package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +13 -0
- package/src/shared/components/ui/tabs.tsx +24 -1
- package/src/shared/hooks/use-server-path-watch.ts +42 -0
- package/src/shared/lib/api/server-path/server-path.types.ts +10 -0
- package/src/shared/lib/api/types.ts +3 -0
- package/src/shared/lib/api/utils/server-path.utils.ts +10 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
- package/src/shared/lib/i18n/locales/en-US/doc-browser.json +49 -7
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
- package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +49 -7
- package/vite.config.ts +32 -1
- package/dist/assets/desktop-update-config-Dirc3bpt.js +0 -1
- package/dist/assets/doc-browser-4Sud6dWw.js +0 -1
- package/dist/assets/doc-browser-D4x_yH5F.js +0 -1
- package/dist/assets/form-actions-B2q4jgjE.js +0 -1
- package/dist/assets/index-CdyTsSBu.css +0 -1
- package/dist/assets/index-Das_bw2h.js +0 -107
- package/dist/assets/loader-circle-CpmBdaon.js +0 -1
- package/dist/assets/mcp-marketplace-page-CdOY3Sop.js +0 -1
- package/dist/assets/mcp-marketplace-page-DvjrfqEt.js +0 -9
- package/dist/assets/plus-C8FkjpSZ.js +0 -1
- package/dist/assets/react-C8XFFvuz.js +0 -8
- package/dist/assets/remote-BJHXa4mB.js +0 -1
- package/dist/assets/rotate-cw-DJ7UF3F2.js +0 -1
- package/dist/assets/security-config-CbDiY2xr.js +0 -1
- package/dist/assets/skeleton-XpqeWL62.js +0 -1
- package/dist/assets/x-odTHAtLZ.js +0 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { AppMarketplaceItemView } from './use-app-marketplace';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const app: AppMarketplaceItemView = {
|
|
5
|
+
id: 'app-personal-organizer',
|
|
6
|
+
slug: 'personal-organizer',
|
|
7
|
+
appId: 'nextclaw.personal-organizer',
|
|
8
|
+
name: '个人空间',
|
|
9
|
+
summary: 'A calm personal space.',
|
|
10
|
+
summaryI18n: { en: 'A calm personal space.' },
|
|
11
|
+
tags: ['personal'],
|
|
12
|
+
latestVersion: '0.1.1',
|
|
13
|
+
featured: true,
|
|
14
|
+
publisher: { id: 'nextclaw', name: 'NextClaw' },
|
|
15
|
+
install: {
|
|
16
|
+
kind: 'registry' as const,
|
|
17
|
+
spec: 'nextclaw.personal-organizer',
|
|
18
|
+
registry: 'https://apps-registry.nextclaw.io/api/v1/apps/registry/',
|
|
19
|
+
},
|
|
20
|
+
webUrl: 'https://apps.nextclaw.io/apps/personal-organizer',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.resetModules();
|
|
25
|
+
vi.unstubAllEnvs();
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('withDevPreviewCover', () => {
|
|
30
|
+
it('uses an explicitly configured preview cover for a known official app', async () => {
|
|
31
|
+
vi.stubEnv('VITE_APP_MARKETPLACE_PREVIEW_COVER_BASE_URL', 'http://127.0.0.1:4178/covers/');
|
|
32
|
+
const { withDevPreviewCover } = await import('./use-app-marketplace');
|
|
33
|
+
|
|
34
|
+
expect(withDevPreviewCover(app).coverUrl).toBe(
|
|
35
|
+
'http://127.0.0.1:4178/covers/personal-organizer.webp',
|
|
36
|
+
);
|
|
37
|
+
expect(withDevPreviewCover(app).coverPreview).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps a Registry cover as the canonical source', async () => {
|
|
41
|
+
vi.stubEnv('VITE_APP_MARKETPLACE_PREVIEW_COVER_BASE_URL', 'http://127.0.0.1:4178/covers');
|
|
42
|
+
const { withDevPreviewCover } = await import('./use-app-marketplace');
|
|
43
|
+
const registryCover = 'https://apps-registry.nextclaw.io/cover.webp';
|
|
44
|
+
|
|
45
|
+
expect(withDevPreviewCover({ ...app, coverUrl: registryCover }).coverUrl).toBe(registryCover);
|
|
46
|
+
expect(withDevPreviewCover({ ...app, coverUrl: registryCover }).coverPreview).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not guess preview assets for third-party apps', async () => {
|
|
50
|
+
vi.stubEnv('VITE_APP_MARKETPLACE_PREVIEW_COVER_BASE_URL', 'http://127.0.0.1:4178/covers');
|
|
51
|
+
const { withDevPreviewCover } = await import('./use-app-marketplace');
|
|
52
|
+
|
|
53
|
+
expect(withDevPreviewCover({
|
|
54
|
+
...app,
|
|
55
|
+
publisher: { id: 'third-party', name: 'Third Party' },
|
|
56
|
+
}).coverUrl).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('fetchAppMarketplace', () => {
|
|
61
|
+
it('uses the cursor catalog endpoint and preserves pagination metadata', async () => {
|
|
62
|
+
const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response(JSON.stringify({
|
|
63
|
+
ok: true,
|
|
64
|
+
data: {
|
|
65
|
+
items: [app],
|
|
66
|
+
hasMore: true,
|
|
67
|
+
nextCursor: 'cursor-2',
|
|
68
|
+
query: 'notes',
|
|
69
|
+
tag: 'personal',
|
|
70
|
+
sort: 'relevance',
|
|
71
|
+
},
|
|
72
|
+
}), { status: 200 }));
|
|
73
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
74
|
+
const { fetchAppMarketplace } = await import('./use-app-marketplace');
|
|
75
|
+
|
|
76
|
+
await expect(fetchAppMarketplace({
|
|
77
|
+
q: 'notes',
|
|
78
|
+
tag: 'personal',
|
|
79
|
+
cursor: 'cursor-1',
|
|
80
|
+
limit: 24,
|
|
81
|
+
})).resolves.toMatchObject({
|
|
82
|
+
hasMore: true,
|
|
83
|
+
nextCursor: 'cursor-2',
|
|
84
|
+
items: [app],
|
|
85
|
+
});
|
|
86
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
87
|
+
expect.stringContaining('/api/v2/apps/items?'),
|
|
88
|
+
expect.any(Object),
|
|
89
|
+
);
|
|
90
|
+
const requestUrl = fetchMock.mock.calls[0]?.[0];
|
|
91
|
+
expect(requestUrl).toBeTypeOf('string');
|
|
92
|
+
const url = new URL(String(requestUrl));
|
|
93
|
+
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
|
94
|
+
q: 'notes',
|
|
95
|
+
tag: 'personal',
|
|
96
|
+
cursor: 'cursor-1',
|
|
97
|
+
limit: '24',
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('uses the legacy catalog only when the server explicitly does not support v2', async () => {
|
|
102
|
+
const fetchMock = vi.fn()
|
|
103
|
+
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
|
104
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
105
|
+
ok: true,
|
|
106
|
+
data: { items: [app], page: 1, pageSize: 100, total: 1, totalPages: 1 },
|
|
107
|
+
}), { status: 200 }));
|
|
108
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
109
|
+
const { fetchAppMarketplace } = await import('./use-app-marketplace');
|
|
110
|
+
|
|
111
|
+
await expect(fetchAppMarketplace({ tags: ['personal'], limit: 24 })).resolves.toMatchObject({
|
|
112
|
+
hasMore: false,
|
|
113
|
+
items: [app],
|
|
114
|
+
});
|
|
115
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
116
|
+
expect(fetchMock.mock.calls[1]?.[0]).toContain('/api/v1/apps/items?');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does not hide a v2 server failure behind the compatibility path', async () => {
|
|
120
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 500 }));
|
|
121
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
122
|
+
const { fetchAppMarketplace } = await import('./use-app-marketplace');
|
|
123
|
+
|
|
124
|
+
await expect(fetchAppMarketplace()).rejects.toThrow('Marketplace request failed (500)');
|
|
125
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('forwards cancellation through both v2 and its explicit compatibility request', async () => {
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
const fetchMock = vi.fn()
|
|
131
|
+
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
|
132
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
133
|
+
ok: true,
|
|
134
|
+
data: { items: [app], page: 1, pageSize: 100, total: 1, totalPages: 1 },
|
|
135
|
+
}), { status: 200 }));
|
|
136
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
137
|
+
const { fetchAppMarketplace } = await import('./use-app-marketplace');
|
|
138
|
+
|
|
139
|
+
await fetchAppMarketplace({}, controller.signal);
|
|
140
|
+
|
|
141
|
+
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ signal: controller.signal });
|
|
142
|
+
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ signal: controller.signal });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -1,95 +1,65 @@
|
|
|
1
|
-
import { useQuery } from '@tanstack/react-query';
|
|
1
|
+
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
fetchAppMarketplace,
|
|
5
|
+
fetchAppMarketplaceDetail,
|
|
6
|
+
withDevPreviewCover,
|
|
7
|
+
} from '@/features/apps/services/app-marketplace.service';
|
|
8
|
+
import type {
|
|
9
|
+
AppMarketplaceCatalogParams,
|
|
10
|
+
AppMarketplaceItemView,
|
|
11
|
+
AppMarketplaceListView,
|
|
12
|
+
} from '@/features/apps/types/app-marketplace.types';
|
|
2
13
|
|
|
3
|
-
|
|
4
|
-
|
|
14
|
+
export { fetchAppMarketplace, withDevPreviewCover };
|
|
15
|
+
export type {
|
|
16
|
+
AppMarketplaceCatalogParams,
|
|
17
|
+
AppMarketplaceCatalogView,
|
|
18
|
+
AppMarketplaceDetailView,
|
|
19
|
+
AppMarketplaceItemView,
|
|
20
|
+
AppMarketplaceListView,
|
|
21
|
+
} from '@/features/apps/types/app-marketplace.types';
|
|
5
22
|
|
|
6
|
-
|
|
7
|
-
id: string;
|
|
8
|
-
slug: string;
|
|
9
|
-
appId: string;
|
|
10
|
-
name: string;
|
|
11
|
-
summary: string;
|
|
12
|
-
summaryI18n: Record<string, string>;
|
|
13
|
-
tags: string[];
|
|
14
|
-
latestVersion: string;
|
|
15
|
-
featured: boolean;
|
|
16
|
-
publisher: {
|
|
17
|
-
id: string;
|
|
18
|
-
name: string;
|
|
19
|
-
url?: string;
|
|
20
|
-
};
|
|
21
|
-
install: {
|
|
22
|
-
kind: 'registry';
|
|
23
|
-
spec: string;
|
|
24
|
-
registry: string;
|
|
25
|
-
};
|
|
26
|
-
webUrl: string;
|
|
27
|
-
};
|
|
23
|
+
const APP_MARKETPLACE_QUERY_KEY = ['app-marketplace', 'v3'] as const;
|
|
28
24
|
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
25
|
+
export function useAppMarketplace(params: AppMarketplaceCatalogParams, enabled: boolean) {
|
|
26
|
+
const query = useInfiniteQuery({
|
|
27
|
+
queryKey: [...APP_MARKETPLACE_QUERY_KEY, params],
|
|
28
|
+
initialPageParam: undefined as string | undefined,
|
|
29
|
+
queryFn: async ({ pageParam, signal }) => await fetchAppMarketplace({
|
|
30
|
+
...params,
|
|
31
|
+
cursor: pageParam,
|
|
32
|
+
limit: 24,
|
|
33
|
+
}, signal),
|
|
34
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
38
35
|
enabled,
|
|
39
36
|
retry: 1,
|
|
40
37
|
staleTime: 5 * 60 * 1000,
|
|
41
38
|
});
|
|
39
|
+
const data = useMemo<AppMarketplaceListView | undefined>(() => {
|
|
40
|
+
if (!query.data) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const byId = new Map<string, AppMarketplaceItemView>();
|
|
44
|
+
for (const page of query.data.pages) {
|
|
45
|
+
for (const item of page.items) {
|
|
46
|
+
byId.set(item.id, item);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
items: [...byId.values()],
|
|
51
|
+
hasMore: query.data.pages.at(-1)?.hasMore ?? false,
|
|
52
|
+
};
|
|
53
|
+
}, [query.data]);
|
|
54
|
+
return { ...query, data };
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const payload: unknown = await response.json();
|
|
54
|
-
if (!isRecord(payload) || payload.ok !== true || !isRecord(payload.data)) {
|
|
55
|
-
throw new Error('Marketplace returned an invalid response');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const items = Array.isArray(payload.data.items)
|
|
59
|
-
? payload.data.items.filter(isAppMarketplaceItem)
|
|
60
|
-
: [];
|
|
61
|
-
return {
|
|
62
|
-
total: typeof payload.data.total === 'number' ? payload.data.total : items.length,
|
|
63
|
-
items,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function isAppMarketplaceItem(value: unknown): value is AppMarketplaceItemView {
|
|
68
|
-
if (!isRecord(value) || !isRecord(value.install) || !isRecord(value.publisher)) {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
return typeof value.id === 'string'
|
|
72
|
-
&& typeof value.slug === 'string'
|
|
73
|
-
&& typeof value.appId === 'string'
|
|
74
|
-
&& typeof value.name === 'string'
|
|
75
|
-
&& typeof value.summary === 'string'
|
|
76
|
-
&& isStringRecord(value.summaryI18n)
|
|
77
|
-
&& Array.isArray(value.tags)
|
|
78
|
-
&& value.tags.every((tag) => typeof tag === 'string')
|
|
79
|
-
&& typeof value.latestVersion === 'string'
|
|
80
|
-
&& typeof value.featured === 'boolean'
|
|
81
|
-
&& typeof value.publisher.id === 'string'
|
|
82
|
-
&& typeof value.publisher.name === 'string'
|
|
83
|
-
&& value.install.kind === 'registry'
|
|
84
|
-
&& typeof value.install.spec === 'string'
|
|
85
|
-
&& typeof value.install.registry === 'string'
|
|
86
|
-
&& typeof value.webUrl === 'string';
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
90
|
-
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
94
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
+
export function useAppMarketplaceDetail(slug: string | null, enabled: boolean) {
|
|
58
|
+
return useQuery({
|
|
59
|
+
queryKey: ['app-marketplace-detail', 'v2', slug],
|
|
60
|
+
queryFn: async ({ signal }) => await fetchAppMarketplaceDetail(slug ?? '', signal),
|
|
61
|
+
enabled: enabled && Boolean(slug),
|
|
62
|
+
retry: 1,
|
|
63
|
+
staleTime: 5 * 60 * 1000,
|
|
64
|
+
});
|
|
95
65
|
}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
1
2
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import type { AppPackageOperationStatus, AppPackageOperationView } from '@nextclaw/client-sdk';
|
|
2
4
|
import { nextclawClient } from '@/shared/lib/api';
|
|
5
|
+
import type { AppPackageOperationSettlementManager } from '@/features/apps/managers/app-package-operation-settlement.manager';
|
|
3
6
|
|
|
4
7
|
const APP_PACKAGES_QUERY_KEY = ['app-packages'] as const;
|
|
5
8
|
const PANEL_APPS_QUERY_KEY = ['panel-apps'] as const;
|
|
6
9
|
const SERVICE_APPS_QUERY_KEY = ['service-apps'] as const;
|
|
7
10
|
const SERVICE_ACTIONS_QUERY_KEY = ['service-actions'] as const;
|
|
11
|
+
const APP_PACKAGE_OPERATIONS_QUERY_KEY = ['app-package-operations'] as const;
|
|
12
|
+
|
|
13
|
+
const ACTIVE_OPERATION_STATUSES = new Set<AppPackageOperationStatus>([
|
|
14
|
+
'queued',
|
|
15
|
+
'resolving',
|
|
16
|
+
'downloading',
|
|
17
|
+
'verifying',
|
|
18
|
+
'installing',
|
|
19
|
+
'finalizing',
|
|
20
|
+
]);
|
|
8
21
|
|
|
9
22
|
export type AppPackageMutationInput =
|
|
10
23
|
| { action: 'enable'; appId: string }
|
|
@@ -22,6 +35,47 @@ export function useAppPackages() {
|
|
|
22
35
|
});
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
export function useAppPackageOperations() {
|
|
39
|
+
return useQuery({
|
|
40
|
+
queryKey: APP_PACKAGE_OPERATIONS_QUERY_KEY,
|
|
41
|
+
queryFn: () => nextclawClient.appPackages.listOperations(),
|
|
42
|
+
refetchInterval: (query) => {
|
|
43
|
+
const entries = query.state.data?.entries ?? [];
|
|
44
|
+
return entries.some((entry) => isAppPackageOperationActive(entry.status)) ? 500 : false;
|
|
45
|
+
},
|
|
46
|
+
staleTime: 0,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isAppPackageOperationActive(status: AppPackageOperationStatus): boolean {
|
|
51
|
+
return ACTIVE_OPERATION_STATUSES.has(status);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useAppPackageOperationSettlement({
|
|
55
|
+
isLoaded,
|
|
56
|
+
operations,
|
|
57
|
+
refetchPackages,
|
|
58
|
+
refetchPanels,
|
|
59
|
+
settlementManager,
|
|
60
|
+
}: {
|
|
61
|
+
isLoaded: boolean;
|
|
62
|
+
operations: AppPackageOperationView[];
|
|
63
|
+
refetchPackages: () => Promise<unknown>;
|
|
64
|
+
refetchPanels: () => Promise<{
|
|
65
|
+
data?: { entries: Array<{ id: string; packageId?: string }> };
|
|
66
|
+
}>;
|
|
67
|
+
settlementManager: AppPackageOperationSettlementManager;
|
|
68
|
+
}) {
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
settlementManager.settle({
|
|
71
|
+
isLoaded,
|
|
72
|
+
operations,
|
|
73
|
+
refetchPackages,
|
|
74
|
+
refetchPanels,
|
|
75
|
+
});
|
|
76
|
+
}, [isLoaded, operations, refetchPackages, refetchPanels, settlementManager]);
|
|
77
|
+
}
|
|
78
|
+
|
|
25
79
|
export function useAppPackageMutation() {
|
|
26
80
|
const queryClient = useQueryClient();
|
|
27
81
|
return useMutation({
|
|
@@ -32,16 +86,16 @@ export function useAppPackageMutation() {
|
|
|
32
86
|
case 'disable':
|
|
33
87
|
return await nextclawClient.appPackages.disable(input.appId);
|
|
34
88
|
case 'install':
|
|
35
|
-
return await nextclawClient.appPackages.
|
|
89
|
+
return await nextclawClient.appPackages.startInstall({
|
|
36
90
|
source: input.source,
|
|
37
91
|
registryUrl: input.registryUrl,
|
|
38
92
|
});
|
|
39
93
|
case 'update':
|
|
40
|
-
return await nextclawClient.appPackages.
|
|
94
|
+
return await nextclawClient.appPackages.startUpdate(input.appId);
|
|
41
95
|
case 'rollback':
|
|
42
|
-
return await nextclawClient.appPackages.
|
|
96
|
+
return await nextclawClient.appPackages.startRollback(input.appId, input.version);
|
|
43
97
|
case 'uninstall':
|
|
44
|
-
return await nextclawClient.appPackages.
|
|
98
|
+
return await nextclawClient.appPackages.startUninstall(
|
|
45
99
|
input.appId,
|
|
46
100
|
input.purgeData ?? false,
|
|
47
101
|
);
|
|
@@ -53,6 +107,7 @@ export function useAppPackageMutation() {
|
|
|
53
107
|
queryClient.invalidateQueries({ queryKey: PANEL_APPS_QUERY_KEY }),
|
|
54
108
|
queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
|
|
55
109
|
queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
|
|
110
|
+
queryClient.invalidateQueries({ queryKey: APP_PACKAGE_OPERATIONS_QUERY_KEY }),
|
|
56
111
|
]);
|
|
57
112
|
},
|
|
58
113
|
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { AppPackageOperationView } from '@nextclaw/client-sdk';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { AppPackageOperationSettlementManager } from '@/features/apps/managers/app-package-operation-settlement.manager';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
error: vi.fn(),
|
|
7
|
+
success: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('sonner', () => ({ toast: mocks }));
|
|
11
|
+
|
|
12
|
+
function operation(
|
|
13
|
+
id: string,
|
|
14
|
+
overrides: Partial<AppPackageOperationView> = {},
|
|
15
|
+
): AppPackageOperationView {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
action: 'update',
|
|
19
|
+
appId: 'nextclaw.personal-organizer',
|
|
20
|
+
status: 'succeeded',
|
|
21
|
+
completedSteps: 5,
|
|
22
|
+
totalSteps: 5,
|
|
23
|
+
createdAt: '2026-08-12T00:00:00.000Z',
|
|
24
|
+
updatedAt: '2026-08-12T00:00:01.000Z',
|
|
25
|
+
completedAt: '2026-08-12T00:00:01.000Z',
|
|
26
|
+
result: {
|
|
27
|
+
appId: 'nextclaw.personal-organizer',
|
|
28
|
+
activeVersion: '0.1.1',
|
|
29
|
+
changed: true,
|
|
30
|
+
},
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('AppPackageOperationSettlementManager', () => {
|
|
36
|
+
it('ignores historical terminal operations and settles each new operation once', async () => {
|
|
37
|
+
const reloadByDedupeKeys = vi.fn();
|
|
38
|
+
const refetchPackages = vi.fn(async () => undefined);
|
|
39
|
+
const refetchPanels = vi.fn(async () => ({
|
|
40
|
+
data: {
|
|
41
|
+
entries: [
|
|
42
|
+
{ id: 'todos', packageId: 'nextclaw.personal-organizer' },
|
|
43
|
+
{ id: 'other', packageId: 'nextclaw.other' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
const manager = new AppPackageOperationSettlementManager({
|
|
48
|
+
reloadByDedupeKeys,
|
|
49
|
+
} as never);
|
|
50
|
+
const historical = operation('historical');
|
|
51
|
+
|
|
52
|
+
manager.settle({
|
|
53
|
+
isLoaded: true,
|
|
54
|
+
operations: [historical],
|
|
55
|
+
refetchPackages,
|
|
56
|
+
refetchPanels,
|
|
57
|
+
});
|
|
58
|
+
expect(mocks.success).not.toHaveBeenCalled();
|
|
59
|
+
|
|
60
|
+
const completed = operation('completed');
|
|
61
|
+
manager.settle({
|
|
62
|
+
isLoaded: true,
|
|
63
|
+
operations: [completed, historical],
|
|
64
|
+
refetchPackages,
|
|
65
|
+
refetchPanels,
|
|
66
|
+
});
|
|
67
|
+
manager.settle({
|
|
68
|
+
isLoaded: true,
|
|
69
|
+
operations: [completed, historical],
|
|
70
|
+
refetchPackages,
|
|
71
|
+
refetchPanels,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await vi.waitFor(() => {
|
|
75
|
+
expect(refetchPackages).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(refetchPanels).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect(reloadByDedupeKeys).toHaveBeenCalledWith(['panel-app:todos']);
|
|
78
|
+
});
|
|
79
|
+
expect(mocks.success).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AppPackageOperationView } from '@nextclaw/client-sdk';
|
|
2
|
+
import { toast } from 'sonner';
|
|
3
|
+
import type { DocBrowserManager } from '@/shared/components/doc-browser/managers/doc-browser.manager';
|
|
4
|
+
import { t } from '@/shared/lib/i18n';
|
|
5
|
+
import { isAppPackageOperationActive } from '@/features/apps/hooks/use-app-packages';
|
|
6
|
+
|
|
7
|
+
type RefetchPanels = () => Promise<{
|
|
8
|
+
data?: { entries: Array<{ id: string; packageId?: string }> };
|
|
9
|
+
}>;
|
|
10
|
+
|
|
11
|
+
export class AppPackageOperationSettlementManager {
|
|
12
|
+
private initialized = false;
|
|
13
|
+
private readonly knownTerminalOperationIds = new Set<string>();
|
|
14
|
+
|
|
15
|
+
constructor(private readonly docBrowserManager: DocBrowserManager) {}
|
|
16
|
+
|
|
17
|
+
readonly settle = ({
|
|
18
|
+
isLoaded,
|
|
19
|
+
operations,
|
|
20
|
+
refetchPackages,
|
|
21
|
+
refetchPanels,
|
|
22
|
+
}: {
|
|
23
|
+
isLoaded: boolean;
|
|
24
|
+
operations: AppPackageOperationView[];
|
|
25
|
+
refetchPackages: () => Promise<unknown>;
|
|
26
|
+
refetchPanels: RefetchPanels;
|
|
27
|
+
}): void => {
|
|
28
|
+
if (!isLoaded) return;
|
|
29
|
+
const terminal = operations.filter(
|
|
30
|
+
(entry) => !isAppPackageOperationActive(entry.status),
|
|
31
|
+
);
|
|
32
|
+
if (!this.initialized) {
|
|
33
|
+
terminal.forEach((entry) => this.knownTerminalOperationIds.add(entry.id));
|
|
34
|
+
this.initialized = true;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
terminal.forEach((operation) => {
|
|
38
|
+
if (this.knownTerminalOperationIds.has(operation.id)) return;
|
|
39
|
+
this.knownTerminalOperationIds.add(operation.id);
|
|
40
|
+
this.settleOperation(operation, refetchPackages, refetchPanels);
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
private readonly settleOperation = (
|
|
45
|
+
operation: AppPackageOperationView,
|
|
46
|
+
refetchPackages: () => Promise<unknown>,
|
|
47
|
+
refetchPanels: RefetchPanels,
|
|
48
|
+
): void => {
|
|
49
|
+
if (operation.status === 'succeeded') {
|
|
50
|
+
toast.success(operationSuccessLabel(operation));
|
|
51
|
+
void Promise.all([refetchPackages(), refetchPanels()]).then(([, panelsResult]) => {
|
|
52
|
+
if (operation.action !== 'rollback' && operation.action !== 'update') return;
|
|
53
|
+
this.docBrowserManager.reloadByDedupeKeys(
|
|
54
|
+
panelsResult.data?.entries
|
|
55
|
+
.filter((entry) => entry.packageId === operation.appId)
|
|
56
|
+
.map((entry) => `panel-app:${entry.id}`) ?? [],
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (operation.status === 'failed' || operation.status === 'interrupted') {
|
|
62
|
+
void refetchPackages();
|
|
63
|
+
void refetchPanels();
|
|
64
|
+
toast.error(operation.error ?? t('appPackagesActionFailed'));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function operationSuccessLabel(operation: AppPackageOperationView): string {
|
|
70
|
+
switch (operation.action) {
|
|
71
|
+
case 'install': return t('appPackagesInstallCompleted');
|
|
72
|
+
case 'update': return operation.result?.changed === false
|
|
73
|
+
? t('appPackagesAlreadyCurrent')
|
|
74
|
+
: t('appPackagesUpdateCompleted');
|
|
75
|
+
case 'rollback': return operation.result?.changed === false
|
|
76
|
+
? t('appPackagesAlreadyOnVersion')
|
|
77
|
+
: t('appPackagesRollbackCompleted');
|
|
78
|
+
case 'uninstall': return t('appPackagesUninstallCompleted');
|
|
79
|
+
}
|
|
80
|
+
}
|