@nextclaw/ui 0.13.3 → 0.13.5
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 +128 -0
- package/dist/assets/api-BAyyGaoz.js +15 -0
- package/dist/assets/app-presenter-provider-DqHHfC1K.js +3 -0
- package/dist/assets/arrow-left-Bn-3Pj3p.js +1 -0
- package/dist/assets/{boxes-BvA-I63l.js → boxes-ClVuotcz.js} +1 -1
- package/dist/assets/channels-list-page-BnGxv6ma.js +8 -0
- package/dist/assets/chat-page-DInyWLb4.js +105 -0
- package/dist/assets/config-split-page-CWN2YG9U.js +1 -0
- package/dist/assets/confirm-dialog-CVbgKuHp.js +5 -0
- package/dist/assets/{createLucideIcon-Br1EFMEr.js → createLucideIcon-CjPUPAAK.js} +1 -1
- package/dist/assets/desktop-update-config-abmYzHSy.js +1 -0
- package/dist/assets/{dist-rzakR_bu.js → dist-hkrb6Ynt.js} +1 -1
- package/dist/assets/doc-browser-BlrpUKVJ.js +1 -0
- package/dist/assets/doc-browser-context-Cb1sEUT0.js +1 -0
- package/dist/assets/doc-browser-kJ2ClG0H.js +1 -0
- package/dist/assets/ellipsis-CdkxFkVZ.js +1 -0
- package/dist/assets/{external-link-DHjFdCpy.js → external-link-CT9_qFwN.js} +1 -1
- package/dist/assets/index-BGXTR0F_.css +1 -0
- package/dist/assets/index-Ccbswzt9.js +101 -0
- package/dist/assets/{key-round-D41Oe35x.js → key-round-xJfR4iRe.js} +1 -1
- package/dist/assets/{book-open-CRotBOF2.js → loader-circle-BI6HJDg5.js} +1 -1
- package/dist/assets/{mcp-marketplace-page-Dvi8mCBd.js → mcp-marketplace-page-CmUKUeno.js} +2 -2
- package/dist/assets/mcp-marketplace-page-DH11bSVx.js +1 -0
- package/dist/assets/model-config-Dbo3zWDl.js +1 -0
- package/dist/assets/notice-card-CFTFtJky.js +1 -0
- package/dist/assets/play-DyBWW2j4.js +1 -0
- package/dist/assets/plus-BlnBmcKE.js +1 -0
- package/dist/assets/popover-DPqa_Mfb.js +1 -0
- package/dist/assets/provider-models-CXUgDxGR.js +1 -0
- package/dist/assets/provider-scoped-model-input-C6xL7zSJ.js +1 -0
- package/dist/assets/providers-list-DOj_7GL8.js +1 -0
- package/dist/assets/react-oAHNoVya.js +1 -0
- package/dist/assets/refresh-cw-o8NSmIYV.js +1 -0
- package/dist/assets/remote-fCE5fLsj.js +1 -0
- package/dist/assets/rotate-cw-DA8EqNAH.js +1 -0
- package/dist/assets/runtime-config-page-Dg8aEgWx.js +1 -0
- package/dist/assets/{save-aYdSdK97.js → save-D4-g0EAq.js} +1 -1
- package/dist/assets/search-G2cMn3M5.js +1 -0
- package/dist/assets/{search-config-G2O1fcRb.js → search-config-DAibVFGR.js} +1 -1
- package/dist/assets/{secrets-config-TenzxYbE.js → secrets-config-CoJzy13_.js} +2 -2
- package/dist/assets/{status-dot-BSMG7ZaC.js → status-dot-D8xx5ZR8.js} +1 -1
- package/dist/assets/{tabs-D1CjpWNX.js → tabs-DxmND60Q.js} +1 -1
- package/dist/assets/{tabs-custom-CRSN1ekg.js → tabs-custom-Cjo0bHw8.js} +1 -1
- package/dist/assets/{tag-chip-DEashiw6.js → tag-chip-DnNWFRMu.js} +1 -1
- package/dist/assets/{trash-2-DSWTsARW.js → trash-2-C9tc-9sQ.js} +1 -1
- package/dist/assets/x-DmLl2yG6.js +1 -0
- package/dist/index.html +24 -23
- package/dist/logos/mimo.svg +9 -0
- package/package.json +8 -8
- package/public/logos/mimo.svg +9 -0
- package/src/app/components/layout/app-layout.tsx +10 -2
- package/src/features/agents/components/agents-page.tsx +6 -4
- package/src/features/chat/hooks/use-ncp-chat-page-data.ts +17 -10
- package/src/features/marketplace/components/marketplace-list-card.tsx +22 -1
- package/src/features/marketplace/components/marketplace-page.tsx +3 -1
- package/src/features/marketplace/hooks/use-marketplace.ts +3 -1
- package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +8 -0
- package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +70 -6
- package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.test.ts +25 -1
- package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.ts +1 -1
- package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.test.ts +42 -0
- package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +22 -0
- package/src/features/service-apps/components/service-apps-panel.test.tsx +88 -0
- package/src/features/service-apps/components/service-apps-panel.tsx +246 -56
- package/src/features/service-apps/hooks/use-service-apps.ts +14 -0
- package/src/features/side-dock/components/side-dock.test.tsx +58 -3
- package/src/features/side-dock/components/side-dock.tsx +77 -24
- package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +37 -34
- package/src/features/side-dock/index.ts +2 -0
- package/src/features/side-dock/managers/side-dock.manager.test.ts +84 -3
- package/src/features/side-dock/managers/side-dock.manager.ts +135 -12
- package/src/features/side-dock/types/side-dock.types.ts +11 -4
- package/src/features/side-dock/utils/side-dock-item.utils.test.ts +8 -8
- package/src/features/side-dock/utils/side-dock-item.utils.ts +13 -5
- package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -2
- package/src/platforms/mobile/components/mobile-app-shell.tsx +4 -1
- package/src/shared/components/config/provider-form-support.test.ts +21 -0
- package/src/shared/components/config/provider-form-support.ts +38 -78
- package/src/shared/components/config/provider-form.tsx +54 -67
- package/src/shared/components/config/providers-list.tsx +211 -73
- package/src/shared/components/doc-browser/doc-browser-context.test.tsx +73 -0
- package/src/shared/components/doc-browser/doc-browser-context.tsx +3 -0
- package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +25 -2
- package/src/shared/components/doc-browser/doc-browser.test.tsx +42 -0
- package/src/shared/components/doc-browser/doc-browser.tsx +38 -7
- package/src/shared/components/doc-browser/index.ts +1 -0
- package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +30 -6
- package/src/shared/components/doc-browser/stores/doc-browser.store.ts +29 -0
- package/src/shared/components/doc-browser/types/doc-browser.types.ts +23 -0
- package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +7 -0
- package/src/shared/components/model-config.tsx +5 -4
- package/src/shared/components/provider-auth-section.tsx +2 -2
- package/src/shared/components/ui/switch.tsx +5 -2
- package/src/shared/hooks/use-config.ts +27 -6
- package/src/shared/lib/api/marketplace.types.ts +193 -0
- package/src/shared/lib/api/types.ts +23 -200
- package/src/shared/lib/api/utils/config.utils.ts +17 -7
- package/src/shared/lib/i18n/index.ts +10 -12
- package/src/shared/lib/i18n/{marketplace.ts → marketplace-labels.utils.ts} +3 -0
- package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +46 -2
- package/src/shared/lib/provider-models/index.test.ts +93 -15
- package/src/shared/lib/provider-models/index.ts +35 -20
- package/src/shared/lib/transport/request-raw-api-response.utils.ts +1 -1
- package/dist/assets/api-DruAcori.js +0 -15
- package/dist/assets/app-presenter-provider-DIcnNB9s.js +0 -3
- package/dist/assets/channels-list-page-wK3l7p5R.js +0 -8
- package/dist/assets/chat-page-DgehvdJT.js +0 -105
- package/dist/assets/config-split-page-BnTO1mAM.js +0 -1
- package/dist/assets/confirm-dialog-CGrKHbAW.js +0 -5
- package/dist/assets/desktop-update-config-CehWxSSg.js +0 -1
- package/dist/assets/dist-CpwDZDtv.js +0 -1
- package/dist/assets/doc-browser-BgxmVsTx.js +0 -1
- package/dist/assets/doc-browser-CZn5-Qbu.js +0 -1
- package/dist/assets/doc-browser-context-B8gk7HMr.js +0 -1
- package/dist/assets/index-DORmh0Cy.css +0 -1
- package/dist/assets/index-DcFpvnge.js +0 -101
- package/dist/assets/mcp-marketplace-page-DBLhJWOJ.js +0 -1
- package/dist/assets/model-config-B-EJXGtO.js +0 -1
- package/dist/assets/play-C-0vo7hi.js +0 -1
- package/dist/assets/plus-Cr_akLp2.js +0 -1
- package/dist/assets/popover-YieElO8c.js +0 -1
- package/dist/assets/provider-scoped-model-input-BPRNGqYn.js +0 -1
- package/dist/assets/providers-list-CtBpL1ZY.js +0 -1
- package/dist/assets/react-B2X_ph0a.js +0 -1
- package/dist/assets/remote-BGHbHoHf.js +0 -1
- package/dist/assets/rotate-cw-jueVCNJQ.js +0 -1
- package/dist/assets/runtime-config-page-C1Hv7mad.js +0 -1
- package/dist/assets/search-CdhgFNwf.js +0 -1
- package/dist/assets/setting-row-BNu0qhOM.js +0 -1
- package/dist/assets/x-D1v_vb54.js +0 -1
- package/src/shared/components/provider-enabled-field.tsx +0 -23
- /package/dist/assets/{config-hints-A7G_jMTU.js → config-hints-TmNgpFHv.js} +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react';
|
|
2
|
-
import { KeyRound, Plus, Search } from 'lucide-react';
|
|
3
|
-
import {
|
|
2
|
+
import { KeyRound, MoreHorizontal, Plus, Search, Trash2 } from 'lucide-react';
|
|
3
|
+
import { useCreateProvider, useDeleteProvider, useProviders, useProviderTemplates, useUpdateProvider } from '@/shared/hooks/use-config';
|
|
4
4
|
import { LogoBadge } from '@/shared/components/common/logo-badge';
|
|
5
5
|
import { PageHeader, PageLayout } from '@/app/components/layout/page-layout';
|
|
6
6
|
import { Button } from '@/shared/components/ui/button';
|
|
7
7
|
import { Input } from '@/shared/components/ui/input';
|
|
8
|
-
import {
|
|
8
|
+
import { Switch } from '@/shared/components/ui/switch';
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
|
|
10
|
+
import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
|
|
9
11
|
import { StatusDot } from '@/shared/components/ui/status-dot';
|
|
10
|
-
import { hintForPath } from '@/shared/lib/config-hints';
|
|
11
12
|
import { t } from '@/shared/lib/i18n';
|
|
12
13
|
import { cn } from '@/shared/lib/utils';
|
|
13
14
|
import { ProviderForm } from '@/shared/components/config/provider-form';
|
|
14
15
|
import { useViewportLayout } from '@/app/hooks/use-viewport-layout';
|
|
15
16
|
import {
|
|
16
|
-
ConfigSelectionCard,
|
|
17
17
|
ConfigSplitEmptyState,
|
|
18
18
|
ConfigSplitPage,
|
|
19
19
|
ConfigSplitPaneBody,
|
|
@@ -33,53 +33,84 @@ function formatBasePreview(base?: string | null) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function sortProvidersForDisplay<T extends {
|
|
36
|
+
function sortProvidersForDisplay<T extends { providerId: string; providerType: string | null }>(
|
|
37
|
+
providers: T[],
|
|
38
|
+
pinnedProviderIds: string[] = [],
|
|
39
|
+
) {
|
|
40
|
+
const pinnedOrder = new Map(pinnedProviderIds.map((providerId, index) => [providerId, index]));
|
|
37
41
|
return providers
|
|
38
42
|
.map((provider, index) => ({ provider, index }))
|
|
39
43
|
.sort((left, right) => {
|
|
40
|
-
const
|
|
41
|
-
const
|
|
44
|
+
const leftPinned = pinnedOrder.get(left.provider.providerId);
|
|
45
|
+
const rightPinned = pinnedOrder.get(right.provider.providerId);
|
|
46
|
+
if (leftPinned !== undefined || rightPinned !== undefined) {
|
|
47
|
+
return (leftPinned ?? Number.MAX_SAFE_INTEGER) - (rightPinned ?? Number.MAX_SAFE_INTEGER);
|
|
48
|
+
}
|
|
49
|
+
const leftPriority = left.provider.providerType === 'nextclaw' ? 1 : 0;
|
|
50
|
+
const rightPriority = right.provider.providerType === 'nextclaw' ? 1 : 0;
|
|
42
51
|
return leftPriority !== rightPriority ? leftPriority - rightPriority : left.index - right.index;
|
|
43
52
|
})
|
|
44
53
|
.map(({ provider }) => provider);
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
function sortTemplatesForPicker<T extends { providerType: string }>(templates: T[]) {
|
|
57
|
+
const zhipuIndex = templates.findIndex((template) => template.providerType === 'zhipu');
|
|
58
|
+
const dashScopeIndex = templates.findIndex((template) => template.providerType === 'dashscope-coding-plan');
|
|
59
|
+
return templates
|
|
60
|
+
.map((template, index) => ({ template, index }))
|
|
61
|
+
.sort((left, right) => {
|
|
62
|
+
const resolveWeight = ({ template, index }: { template: T; index: number }) => {
|
|
63
|
+
if (template.providerType === 'nextclaw') return Number.MAX_SAFE_INTEGER;
|
|
64
|
+
if (template.providerType === 'deepseek' && dashScopeIndex >= 0) return dashScopeIndex - 0.5;
|
|
65
|
+
if (template.providerType === 'mimo' && zhipuIndex >= 0) return zhipuIndex + 0.5;
|
|
66
|
+
return index;
|
|
67
|
+
};
|
|
68
|
+
return resolveWeight(left) - resolveWeight(right);
|
|
69
|
+
})
|
|
70
|
+
.map(({ template }) => template);
|
|
71
|
+
}
|
|
72
|
+
|
|
47
73
|
export function ProvidersList() {
|
|
48
74
|
const { isMobile } = useViewportLayout();
|
|
49
|
-
const { data:
|
|
50
|
-
const { data:
|
|
51
|
-
const { data: schema } = useConfigSchema();
|
|
75
|
+
const { data: providersView } = useProviders();
|
|
76
|
+
const { data: templatesView } = useProviderTemplates();
|
|
52
77
|
const createProvider = useCreateProvider();
|
|
53
|
-
const
|
|
78
|
+
const deleteProvider = useDeleteProvider();
|
|
79
|
+
const updateProvider = useUpdateProvider();
|
|
54
80
|
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
|
81
|
+
const [providerToDelete, setProviderToDelete] = useState<string | null>(null);
|
|
82
|
+
const [providerPickerOpen, setProviderPickerOpen] = useState(false);
|
|
83
|
+
const [recentProviderIds, setRecentProviderIds] = useState<string[]>([]);
|
|
55
84
|
const [query, setQuery] = useState('');
|
|
56
|
-
const providers = useMemo(
|
|
57
|
-
|
|
85
|
+
const providers = useMemo(
|
|
86
|
+
() => sortProvidersForDisplay(Object.values(providersView?.providers ?? {}), recentProviderIds),
|
|
87
|
+
[providersView?.providers, recentProviderIds],
|
|
88
|
+
);
|
|
89
|
+
const templates = useMemo(() => templatesView?.providerTemplates ?? [], [templatesView?.providerTemplates]);
|
|
90
|
+
const pickerTemplates = useMemo(() => sortTemplatesForPicker(templates), [templates]);
|
|
91
|
+
const templateByType = useMemo(() => new Map(templates.map((template) => [template.providerType, template])), [templates]);
|
|
58
92
|
|
|
59
93
|
const filteredProviders = useMemo(() => {
|
|
60
94
|
const keyword = query.trim().toLowerCase();
|
|
61
95
|
return providers
|
|
62
|
-
.filter((provider) =>
|
|
63
|
-
activeTab !== 'installed' || Boolean(providersConfig[provider.name]?.enabled !== false && providersConfig[provider.name]?.apiKeySet),
|
|
64
|
-
)
|
|
65
96
|
.filter((provider) => {
|
|
66
97
|
if (!keyword) {
|
|
67
98
|
return true;
|
|
68
99
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
return display.toLowerCase().includes(keyword) || provider.
|
|
100
|
+
const template = provider.providerType ? templateByType.get(provider.providerType) : null;
|
|
101
|
+
const display = provider.displayName?.trim() || template?.displayName || provider.providerId;
|
|
102
|
+
return display.toLowerCase().includes(keyword) || provider.providerId.toLowerCase().includes(keyword);
|
|
72
103
|
});
|
|
73
|
-
}, [
|
|
104
|
+
}, [providers, query, templateByType]);
|
|
74
105
|
|
|
75
106
|
const resolvedSelectedProvider = useMemo(() => {
|
|
76
|
-
if (selectedProvider && filteredProviders.some((provider) => provider.
|
|
107
|
+
if (selectedProvider && filteredProviders.some((provider) => provider.providerId === selectedProvider)) {
|
|
77
108
|
return selectedProvider;
|
|
78
109
|
}
|
|
79
|
-
return isMobile ? null : filteredProviders[0]?.
|
|
110
|
+
return isMobile ? null : filteredProviders[0]?.providerId ?? null;
|
|
80
111
|
}, [filteredProviders, isMobile, selectedProvider]);
|
|
81
112
|
|
|
82
|
-
if (!
|
|
113
|
+
if (!providersView || !templatesView) {
|
|
83
114
|
return <div className="p-8">{t('providersLoading')}</div>;
|
|
84
115
|
}
|
|
85
116
|
|
|
@@ -94,38 +125,67 @@ export function ProvidersList() {
|
|
|
94
125
|
>
|
|
95
126
|
<ConfigSplitSidebar>
|
|
96
127
|
<ConfigSplitPaneHeader className="space-y-3 px-4 pb-3 pt-4">
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
<Popover open={providerPickerOpen} onOpenChange={setProviderPickerOpen}>
|
|
129
|
+
<PopoverTrigger asChild>
|
|
130
|
+
<Button type="button" variant="outline" className="w-full justify-center" disabled={createProvider.isPending}>
|
|
131
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
132
|
+
{createProvider.isPending ? t('saving') : t('providerAdd')}
|
|
133
|
+
</Button>
|
|
134
|
+
</PopoverTrigger>
|
|
135
|
+
<PopoverContent className="w-[42rem] max-w-[calc(100vw-2rem)] p-3" align="start">
|
|
136
|
+
<div className="mb-2 px-1 text-xs font-semibold text-gray-500">{t('providerTemplatePickerTitle')}</div>
|
|
137
|
+
<div className="grid max-h-[24rem] grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
className="flex min-h-20 w-full flex-col items-start gap-2 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-2 text-left hover:border-primary/40 hover:bg-gray-50"
|
|
141
|
+
onClick={async () => {
|
|
142
|
+
try {
|
|
143
|
+
const result = await createProvider.mutateAsync({ data: { providerType: null } });
|
|
144
|
+
setQuery('');
|
|
145
|
+
setSelectedProvider(result.providerId);
|
|
146
|
+
setRecentProviderIds((current) => [result.providerId, ...current.filter((providerId) => providerId !== result.providerId)]);
|
|
147
|
+
setProviderPickerOpen(false);
|
|
148
|
+
} catch {
|
|
149
|
+
// toast handled in hook
|
|
150
|
+
}
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<LogoBadge name="custom" className="h-8 w-8 rounded-lg border border-gray-200 bg-white" />
|
|
154
|
+
<span className="line-clamp-2 min-w-0 text-xs font-semibold leading-4 text-gray-800">
|
|
155
|
+
{t('providerAddCustom')}
|
|
156
|
+
</span>
|
|
157
|
+
</button>
|
|
158
|
+
{pickerTemplates.map((template) => (
|
|
159
|
+
<button
|
|
160
|
+
key={template.providerType}
|
|
161
|
+
type="button"
|
|
162
|
+
className="flex min-h-20 w-full flex-col items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-left hover:border-primary/40 hover:bg-gray-50"
|
|
163
|
+
onClick={async () => {
|
|
164
|
+
try {
|
|
165
|
+
const result = await createProvider.mutateAsync({ data: { providerType: template.providerType } });
|
|
166
|
+
setQuery('');
|
|
167
|
+
setSelectedProvider(result.providerId);
|
|
168
|
+
setRecentProviderIds((current) => [result.providerId, ...current.filter((providerId) => providerId !== result.providerId)]);
|
|
169
|
+
setProviderPickerOpen(false);
|
|
170
|
+
} catch {
|
|
171
|
+
// toast handled in hook
|
|
172
|
+
}
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<LogoBadge
|
|
176
|
+
name={template.providerType}
|
|
177
|
+
src={template.logo ? `/logos/${template.logo}` : null}
|
|
178
|
+
className="h-8 w-8 rounded-lg border border-gray-200 bg-white"
|
|
179
|
+
imgClassName="h-4 w-4 object-contain"
|
|
180
|
+
/>
|
|
181
|
+
<span className="line-clamp-2 min-w-0 text-xs font-semibold leading-4 text-gray-800">
|
|
182
|
+
{template.displayName || template.providerType}
|
|
183
|
+
</span>
|
|
184
|
+
</button>
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
</PopoverContent>
|
|
188
|
+
</Popover>
|
|
129
189
|
</ConfigSplitPaneHeader>
|
|
130
190
|
|
|
131
191
|
<div className="border-b border-gray-100 px-4 py-3">
|
|
@@ -142,42 +202,98 @@ export function ProvidersList() {
|
|
|
142
202
|
|
|
143
203
|
<ConfigSplitPaneBody className="space-y-2 p-3">
|
|
144
204
|
{filteredProviders.map((provider) => {
|
|
145
|
-
const
|
|
146
|
-
const isEnabled =
|
|
147
|
-
const isReady = Boolean(isEnabled &&
|
|
148
|
-
const providerLabel =
|
|
149
|
-
const description = formatBasePreview(
|
|
205
|
+
const template = provider.providerType ? templateByType.get(provider.providerType) : null;
|
|
206
|
+
const isEnabled = provider.enabled !== false;
|
|
207
|
+
const isReady = Boolean(isEnabled && provider.apiKeySet);
|
|
208
|
+
const providerLabel = provider.displayName?.trim() || template?.displayName || provider.providerId;
|
|
209
|
+
const description = formatBasePreview(provider.apiBase || template?.defaultApiBase || '') || t('providersDefaultDescription');
|
|
150
210
|
|
|
151
211
|
return (
|
|
152
|
-
<
|
|
153
|
-
key={provider.
|
|
154
|
-
onClick={() => setSelectedProvider(provider.
|
|
155
|
-
|
|
212
|
+
<div
|
|
213
|
+
key={provider.providerId}
|
|
214
|
+
onClick={() => setSelectedProvider(provider.providerId)}
|
|
215
|
+
onKeyDown={(event) => {
|
|
216
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
setSelectedProvider(provider.providerId);
|
|
219
|
+
}
|
|
220
|
+
}}
|
|
221
|
+
role="button"
|
|
222
|
+
tabIndex={0}
|
|
223
|
+
className={cn(
|
|
224
|
+
'w-full rounded-xl border p-2.5 text-left transition-all',
|
|
225
|
+
resolvedSelectedProvider === provider.providerId
|
|
226
|
+
? 'border-primary/30 bg-primary-50/40 shadow-sm'
|
|
227
|
+
: 'border-gray-200/70 bg-white hover:border-gray-300 hover:bg-gray-50/70',
|
|
228
|
+
)}
|
|
156
229
|
>
|
|
157
|
-
<div className="
|
|
158
|
-
<div className="flex min-w-0 items-center gap-3">
|
|
230
|
+
<div className="relative min-h-10">
|
|
231
|
+
<div className="flex min-w-0 items-center gap-3 pr-20">
|
|
159
232
|
<LogoBadge
|
|
160
|
-
name={provider.
|
|
161
|
-
src={
|
|
233
|
+
name={provider.providerType ?? provider.providerId}
|
|
234
|
+
src={template?.logo ? `/logos/${template.logo}` : null}
|
|
162
235
|
className={cn(
|
|
163
236
|
'h-10 w-10 rounded-lg border',
|
|
164
237
|
isReady ? 'border-primary/30 bg-white' : 'border-gray-200/70 bg-white',
|
|
165
238
|
)}
|
|
166
239
|
imgClassName="h-5 w-5 object-contain"
|
|
167
|
-
fallback={<span className="text-sm font-semibold uppercase text-gray-500">{provider.
|
|
240
|
+
fallback={<span className="text-sm font-semibold uppercase text-gray-500">{provider.providerId[0]}</span>}
|
|
168
241
|
/>
|
|
169
242
|
<div className="min-w-0">
|
|
170
243
|
<p className="truncate text-sm font-semibold text-gray-900">{providerLabel}</p>
|
|
171
|
-
<p className="
|
|
244
|
+
<p className="truncate font-mono text-[10px] text-gray-400">{provider.providerId}</p>
|
|
245
|
+
<p className="line-clamp-1 text-[11px] text-gray-500">
|
|
246
|
+
{description} · {provider.models?.length ?? 0}
|
|
247
|
+
</p>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
<div className="absolute right-0 top-0 flex items-center gap-1.5">
|
|
251
|
+
<div onClick={(event) => event.stopPropagation()}>
|
|
252
|
+
<Switch
|
|
253
|
+
checked={isEnabled}
|
|
254
|
+
disabled={updateProvider.isPending}
|
|
255
|
+
title={isEnabled ? t('disabled') : t('enabled')}
|
|
256
|
+
className="h-[18px] w-8"
|
|
257
|
+
thumbClassName="h-4 w-4 data-[state=checked]:translate-x-4"
|
|
258
|
+
onCheckedChange={(enabled) => {
|
|
259
|
+
void updateProvider.mutateAsync({
|
|
260
|
+
provider: provider.providerId,
|
|
261
|
+
data: { enabled },
|
|
262
|
+
silentSuccess: true,
|
|
263
|
+
});
|
|
264
|
+
}}
|
|
265
|
+
/>
|
|
172
266
|
</div>
|
|
267
|
+
<Popover>
|
|
268
|
+
<PopoverTrigger asChild>
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
|
272
|
+
onClick={(event) => event.stopPropagation()}
|
|
273
|
+
title={t('more')}
|
|
274
|
+
>
|
|
275
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
276
|
+
</button>
|
|
277
|
+
</PopoverTrigger>
|
|
278
|
+
<PopoverContent className="w-40 p-1" align="end" onClick={(event) => event.stopPropagation()}>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm text-red-600 hover:bg-red-50"
|
|
282
|
+
onClick={() => setProviderToDelete(provider.providerId)}
|
|
283
|
+
>
|
|
284
|
+
<Trash2 className="h-4 w-4" />
|
|
285
|
+
{t('providerDelete')}
|
|
286
|
+
</button>
|
|
287
|
+
</PopoverContent>
|
|
288
|
+
</Popover>
|
|
173
289
|
</div>
|
|
174
290
|
<StatusDot
|
|
175
291
|
status={isEnabled ? (isReady ? 'ready' : 'setup') : 'inactive'}
|
|
176
292
|
label={isEnabled ? (isReady ? t('statusReady') : t('statusSetup')) : t('disabled')}
|
|
177
|
-
className="
|
|
293
|
+
className="absolute bottom-0 right-0 justify-end"
|
|
178
294
|
/>
|
|
179
295
|
</div>
|
|
180
|
-
</
|
|
296
|
+
</div>
|
|
181
297
|
);
|
|
182
298
|
})}
|
|
183
299
|
|
|
@@ -190,12 +306,34 @@ export function ProvidersList() {
|
|
|
190
306
|
<ProviderForm
|
|
191
307
|
providerName={resolvedSelectedProvider ?? undefined}
|
|
192
308
|
onProviderDeleted={(deletedProvider) => {
|
|
309
|
+
setRecentProviderIds((current) => current.filter((providerId) => providerId !== deletedProvider));
|
|
193
310
|
if (deletedProvider === resolvedSelectedProvider) {
|
|
194
311
|
setSelectedProvider(null);
|
|
195
312
|
}
|
|
196
313
|
}}
|
|
197
314
|
/>
|
|
198
315
|
</ConfigSplitPage>
|
|
316
|
+
<ConfirmDialog
|
|
317
|
+
open={Boolean(providerToDelete)}
|
|
318
|
+
onOpenChange={(open) => {
|
|
319
|
+
if (!open) setProviderToDelete(null);
|
|
320
|
+
}}
|
|
321
|
+
title={t('providerDelete')}
|
|
322
|
+
description={providerToDelete ?? undefined}
|
|
323
|
+
confirmLabel={t('delete')}
|
|
324
|
+
variant="destructive"
|
|
325
|
+
onCancel={() => setProviderToDelete(null)}
|
|
326
|
+
onConfirm={() => {
|
|
327
|
+
if (!providerToDelete) return;
|
|
328
|
+
void deleteProvider.mutateAsync({ provider: providerToDelete }).then(() => {
|
|
329
|
+
setRecentProviderIds((current) => current.filter((providerId) => providerId !== providerToDelete));
|
|
330
|
+
if (providerToDelete === resolvedSelectedProvider) {
|
|
331
|
+
setSelectedProvider(null);
|
|
332
|
+
}
|
|
333
|
+
setProviderToDelete(null);
|
|
334
|
+
});
|
|
335
|
+
}}
|
|
336
|
+
/>
|
|
199
337
|
</PageLayout>
|
|
200
338
|
);
|
|
201
339
|
}
|
|
@@ -164,7 +164,9 @@ describe('DocBrowserProvider dedupe keys', () => {
|
|
|
164
164
|
kind: 'apps',
|
|
165
165
|
});
|
|
166
166
|
});
|
|
167
|
+
});
|
|
167
168
|
|
|
169
|
+
describe('DocBrowserProvider built-in routes', () => {
|
|
168
170
|
it('opens the built-in start page without falling back to docs', () => {
|
|
169
171
|
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
170
172
|
|
|
@@ -211,15 +213,19 @@ describe('DocBrowserProvider dedupe keys', () => {
|
|
|
211
213
|
title: 'Service Apps',
|
|
212
214
|
});
|
|
213
215
|
});
|
|
216
|
+
});
|
|
214
217
|
|
|
218
|
+
describe('DocBrowserProvider panel app resources', () => {
|
|
215
219
|
it('opens an already resolved panel app target without reparsing its content URL', () => {
|
|
216
220
|
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
217
221
|
|
|
218
222
|
act(() => {
|
|
219
223
|
result.current.openTarget({
|
|
220
224
|
dedupeKey: 'panel-app:demo',
|
|
225
|
+
dockIcon: { type: 'url', url: '/api/panel-apps/demo/assets/icon.png' },
|
|
221
226
|
historyPolicy: 'managed',
|
|
222
227
|
kind: 'panel-app',
|
|
228
|
+
resourceUri: 'nextclaw://panel-app/demo',
|
|
223
229
|
title: 'Demo Panel App',
|
|
224
230
|
url: '/api/panel-apps/demo/content',
|
|
225
231
|
});
|
|
@@ -228,11 +234,15 @@ describe('DocBrowserProvider dedupe keys', () => {
|
|
|
228
234
|
expect(result.current.currentTab).toMatchObject({
|
|
229
235
|
currentUrl: '/api/panel-apps/demo/content',
|
|
230
236
|
dedupeKey: 'panel-app:demo',
|
|
237
|
+
dockIcon: { type: 'url', url: '/api/panel-apps/demo/assets/icon.png' },
|
|
231
238
|
kind: 'panel-app',
|
|
239
|
+
resourceUri: 'nextclaw://panel-app/demo',
|
|
232
240
|
title: 'Demo Panel App',
|
|
233
241
|
});
|
|
234
242
|
});
|
|
243
|
+
});
|
|
235
244
|
|
|
245
|
+
describe('DocBrowserProvider managed history', () => {
|
|
236
246
|
it('uses managed history for non-doc routes', () => {
|
|
237
247
|
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
238
248
|
|
|
@@ -335,6 +345,44 @@ describe('DocBrowserProvider dedupe keys', () => {
|
|
|
335
345
|
});
|
|
336
346
|
});
|
|
337
347
|
|
|
348
|
+
it('restores panel app history by stable resource URI instead of reparsing content URL as an app id', () => {
|
|
349
|
+
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
350
|
+
|
|
351
|
+
act(() => {
|
|
352
|
+
result.current.openTarget({
|
|
353
|
+
dedupeKey: 'panel-app:demo',
|
|
354
|
+
historyPolicy: 'managed',
|
|
355
|
+
kind: 'panel-app',
|
|
356
|
+
resourceUri: 'nextclaw://panel-app/demo',
|
|
357
|
+
title: 'Demo Panel App',
|
|
358
|
+
url: '/api/panel-apps/demo/content',
|
|
359
|
+
});
|
|
360
|
+
result.current.openNewTab();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(result.current.currentTab).toMatchObject({
|
|
364
|
+
currentUrl: 'nextclaw://new-tab',
|
|
365
|
+
kind: 'home',
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
act(() => {
|
|
369
|
+
result.current.goBack();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(result.current.currentTab).toMatchObject({
|
|
373
|
+
currentUrl: '/api/panel-apps/demo/content',
|
|
374
|
+
dedupeKey: 'panel-app:demo',
|
|
375
|
+
kind: 'panel-app',
|
|
376
|
+
resourceUri: 'nextclaw://panel-app/demo',
|
|
377
|
+
title: 'Demo Panel App',
|
|
378
|
+
});
|
|
379
|
+
expect(result.current.currentTab?.currentUrl).not.toBe('/api/panel-apps/api/content');
|
|
380
|
+
expect(result.current.activeHistory[result.current.activeHistoryIndex]).toMatchObject({
|
|
381
|
+
resourceUri: 'nextclaw://panel-app/demo',
|
|
382
|
+
url: '/api/panel-apps/demo/content',
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
338
386
|
it('truncates forward history before appending manual navigation after going back', () => {
|
|
339
387
|
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
340
388
|
|
|
@@ -377,6 +425,31 @@ describe('DocBrowserProvider dedupe keys', () => {
|
|
|
377
425
|
});
|
|
378
426
|
|
|
379
427
|
describe('DocBrowserProvider persistence', () => {
|
|
428
|
+
it('persists dock icon metadata for restored tabs', async () => {
|
|
429
|
+
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
430
|
+
|
|
431
|
+
act(() => {
|
|
432
|
+
result.current.openTarget({
|
|
433
|
+
dedupeKey: 'panel-app:demo',
|
|
434
|
+
dockIcon: { type: 'text', value: 'D' },
|
|
435
|
+
historyPolicy: 'managed',
|
|
436
|
+
kind: 'panel-app',
|
|
437
|
+
resourceUri: 'nextclaw://panel-app/demo',
|
|
438
|
+
title: 'Demo App',
|
|
439
|
+
url: '/api/panel-apps/demo/content',
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const savedState = window.localStorage.getItem(docBrowserStorageKey);
|
|
444
|
+
await resetMemoryThenRehydrate(savedState);
|
|
445
|
+
const restored = renderHook(() => useDocBrowser(), { wrapper });
|
|
446
|
+
|
|
447
|
+
expect(restored.result.current.currentTab).toMatchObject({
|
|
448
|
+
dockIcon: { type: 'text', value: 'D' },
|
|
449
|
+
resourceUri: 'nextclaw://panel-app/demo',
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
380
453
|
it('restores the open panel and active tab from the persisted Zustand store', async () => {
|
|
381
454
|
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
382
455
|
|
|
@@ -6,6 +6,9 @@ import type { DocBrowserContextValue } from '@/shared/components/doc-browser/typ
|
|
|
6
6
|
export type {
|
|
7
7
|
DocBrowserActions,
|
|
8
8
|
DocBrowserContextValue,
|
|
9
|
+
DocBrowserDockControls,
|
|
10
|
+
DocBrowserDockIcon,
|
|
11
|
+
DocBrowserDockState,
|
|
9
12
|
DocBrowserMode,
|
|
10
13
|
DocBrowserOpenOptions,
|
|
11
14
|
DocBrowserRouteResolver,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PointerEvent } from 'react';
|
|
2
|
-
import { ArrowLeft, ArrowRight, Maximize2, PanelRightOpen, Plus, X } from 'lucide-react';
|
|
3
|
-
import type { DocBrowserTab } from './doc-browser-context';
|
|
2
|
+
import { ArrowLeft, ArrowRight, Maximize2, PanelRightOpen, Pin, PinOff, Plus, X } from 'lucide-react';
|
|
3
|
+
import type { DocBrowserDockState, DocBrowserTab } from './doc-browser-context';
|
|
4
4
|
import { cn } from '@/shared/lib/utils';
|
|
5
5
|
import { t } from '@/shared/lib/i18n';
|
|
6
6
|
|
|
@@ -11,9 +11,11 @@ type DocBrowserTabStripProps = {
|
|
|
11
11
|
canGoForward: boolean;
|
|
12
12
|
isDocked: boolean;
|
|
13
13
|
isFullscreen: boolean;
|
|
14
|
+
dockState?: DocBrowserDockState;
|
|
14
15
|
onGoBack: () => void;
|
|
15
16
|
onGoForward: () => void;
|
|
16
17
|
onOpenNewTab: () => void;
|
|
18
|
+
onToggleDock?: () => void;
|
|
17
19
|
onSetActiveTab: (tabId: string) => void;
|
|
18
20
|
onCloseTab: (tabId: string) => void;
|
|
19
21
|
onClose: () => void;
|
|
@@ -28,9 +30,11 @@ export function DocBrowserTabStrip({
|
|
|
28
30
|
canGoForward,
|
|
29
31
|
isDocked,
|
|
30
32
|
isFullscreen,
|
|
33
|
+
dockState,
|
|
31
34
|
onGoBack,
|
|
32
35
|
onGoForward,
|
|
33
36
|
onOpenNewTab,
|
|
37
|
+
onToggleDock,
|
|
34
38
|
onSetActiveTab,
|
|
35
39
|
onCloseTab,
|
|
36
40
|
onClose,
|
|
@@ -116,6 +120,25 @@ export function DocBrowserTabStrip({
|
|
|
116
120
|
>
|
|
117
121
|
<Plus className="w-3.5 h-3.5" />
|
|
118
122
|
</button>
|
|
123
|
+
{dockState?.canDock ? (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={onToggleDock}
|
|
127
|
+
disabled={dockState.isDocked && !dockState.removable}
|
|
128
|
+
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:text-gray-300 disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-gray-300"
|
|
129
|
+
title={
|
|
130
|
+
dockState.isDocked
|
|
131
|
+
? dockState.removable ? t('sideDockUnpinCurrent') : t('sideDockBuiltInDocked')
|
|
132
|
+
: t('sideDockPinCurrent')
|
|
133
|
+
}
|
|
134
|
+
>
|
|
135
|
+
{dockState.isDocked && dockState.removable ? (
|
|
136
|
+
<PinOff className="w-3.5 h-3.5" />
|
|
137
|
+
) : (
|
|
138
|
+
<Pin className="w-3.5 h-3.5" />
|
|
139
|
+
)}
|
|
140
|
+
</button>
|
|
141
|
+
) : null}
|
|
119
142
|
{!isFullscreen ? (
|
|
120
143
|
<button
|
|
121
144
|
type="button"
|
|
@@ -237,6 +237,48 @@ describe("DocBrowser", () => {
|
|
|
237
237
|
expect(docBrowserState.openNewTab).toHaveBeenCalled();
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
+
it("pins the current tab through injected dock controls", () => {
|
|
241
|
+
const pinTab = vi.fn();
|
|
242
|
+
const unpinTab = vi.fn();
|
|
243
|
+
|
|
244
|
+
render(<DocBrowser dockControls={{
|
|
245
|
+
getDockState: () => ({ canDock: true, isDocked: false, removable: false }),
|
|
246
|
+
pinTab,
|
|
247
|
+
unpinTab,
|
|
248
|
+
}} />);
|
|
249
|
+
|
|
250
|
+
fireEvent.click(screen.getByTitle("Pin to SideDock"));
|
|
251
|
+
|
|
252
|
+
expect(pinTab).toHaveBeenCalledWith(docBrowserState.currentTab);
|
|
253
|
+
expect(unpinTab).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("unpins a removable docked tab through injected dock controls", () => {
|
|
257
|
+
const pinTab = vi.fn();
|
|
258
|
+
const unpinTab = vi.fn();
|
|
259
|
+
|
|
260
|
+
render(<DocBrowser dockControls={{
|
|
261
|
+
getDockState: () => ({ canDock: true, isDocked: true, removable: true }),
|
|
262
|
+
pinTab,
|
|
263
|
+
unpinTab,
|
|
264
|
+
}} />);
|
|
265
|
+
|
|
266
|
+
fireEvent.click(screen.getByTitle("Remove from SideDock"));
|
|
267
|
+
|
|
268
|
+
expect(unpinTab).toHaveBeenCalledWith(docBrowserState.currentTab);
|
|
269
|
+
expect(pinTab).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("disables dock controls for built-in shortcuts", () => {
|
|
273
|
+
render(<DocBrowser dockControls={{
|
|
274
|
+
getDockState: () => ({ canDock: true, isDocked: true, removable: false }),
|
|
275
|
+
pinTab: vi.fn(),
|
|
276
|
+
unpinTab: vi.fn(),
|
|
277
|
+
}} />);
|
|
278
|
+
|
|
279
|
+
expect(screen.getByTitle("Built-in shortcut")).toHaveProperty("disabled", true);
|
|
280
|
+
});
|
|
281
|
+
|
|
240
282
|
it("renders a start page for home tabs", () => {
|
|
241
283
|
const homeTab: DocBrowserTab = {
|
|
242
284
|
id: "home",
|