feat(extensions): mobile-friendly layout for extensions and add-extension pages

- Stack the extensions page header vertically on small screens, let the
  filter Tabs scroll horizontally if they overflow, hide the debug
  button label below sm and let the install/debug controls wrap.
- Constrain the debug popover and its inputs to the viewport width so
  they no longer overflow on phone-sized screens.
- Drop the card grid from a fixed 30rem column to a min(100%, 22rem)
  column at base / 28rem at sm, and reduce the gap, so cards render
  cleanly at 360px+ widths in both flat and grouped views.
- Make the add-extension header actions wrap on lg- viewports and the
  install dialog responsive instead of a hard 500px box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Junyan Qin
2026-05-10 00:15:55 +08:00
parent 6f97877a5a
commit dd809d36f8
5 changed files with 216 additions and 138 deletions
+38 -17
View File
@@ -56,7 +56,9 @@ function AddExtensionContent() {
} = usePluginInstallTasks(); } = usePluginInstallTasks();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [installInfo, setInstallInfo] = useState<Record<string, string>>({}); const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
const [installExtensionType, setInstallExtensionType] = useState<'plugin' | 'mcp' | 'skill'>('plugin'); const [installExtensionType, setInstallExtensionType] = useState<
'plugin' | 'mcp' | 'skill'
>('plugin');
const [pluginInstallStatus, setPluginInstallStatus] = const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM); useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
@@ -120,29 +122,38 @@ function AddExtensionContent() {
<> <>
<Button <Button
variant="default" variant="default"
className="px-4 py-2 cursor-pointer" className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
onClick={() => navigate('/home/mcp?id=new')} onClick={() => navigate('/home/mcp?id=new')}
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
{t('mcp.addMCPServer')} <span className="whitespace-nowrap">{t('mcp.addMCPServer')}</span>
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="default" className="px-4 py-2 cursor-pointer"> <Button
variant="default"
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
>
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
{t('skills.addSkill')} <span className="whitespace-nowrap">{t('skills.addSkill')}</span>
<ChevronDownIcon className="ml-2 w-4 h-4" /> <ChevronDownIcon className="ml-1 sm:ml-2 w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate('/home/skills?action=create')}> <DropdownMenuItem
onClick={() => navigate('/home/skills?action=create')}
>
{t('skills.createManually')} {t('skills.createManually')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/home/skills?action=upload')}> <DropdownMenuItem
onClick={() => navigate('/home/skills?action=upload')}
>
{t('skills.uploadZip')} {t('skills.uploadZip')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/home/skills?action=github')}> <DropdownMenuItem
onClick={() => navigate('/home/skills?action=github')}
>
{t('skills.importFromGithub')} {t('skills.importFromGithub')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -150,17 +161,24 @@ function AddExtensionContent() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="default" className="px-4 py-2 cursor-pointer"> <Button
variant="default"
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
>
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
{t('plugins.newPlugin')} <span className="whitespace-nowrap">{t('plugins.newPlugin')}</span>
<ChevronDownIcon className="ml-2 w-4 h-4" /> <ChevronDownIcon className="ml-1 sm:ml-2 w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate('/home/add-plugin?action=github')}> <DropdownMenuItem
onClick={() => navigate('/home/add-plugin?action=github')}
>
{t('plugins.installFromGithub')} {t('plugins.installFromGithub')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/home/add-plugin?action=upload')}> <DropdownMenuItem
onClick={() => navigate('/home/add-plugin?action=upload')}
>
{t('plugins.uploadLocal')} {t('plugins.uploadLocal')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -172,7 +190,10 @@ function AddExtensionContent() {
<> <>
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<MarketPage installPlugin={handleInstallPlugin} headerActions={extensionActions} /> <MarketPage
installPlugin={handleInstallPlugin}
headerActions={extensionActions}
/>
</div> </div>
</div> </div>
@@ -185,7 +206,7 @@ function AddExtensionContent() {
} }
}} }}
> >
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"> <DialogContent className="w-[calc(100vw-2rem)] sm:w-[500px] sm:max-w-[500px] max-h-[80vh] p-4 sm:p-6 overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-4"> <DialogTitle className="flex items-center gap-4">
<Download className="size-6" /> <Download className="size-6" />
@@ -238,4 +259,4 @@ function AddExtensionContent() {
</Dialog> </Dialog>
</> </>
); );
} }
@@ -462,7 +462,7 @@ const PluginInstalledComponent = forwardRef<
({group.items.length}) ({group.items.length})
</span> </span>
</div> </div>
<div className="px-[0.8rem] grid gap-8 [grid-template-columns:repeat(auto-fill,minmax(30rem,1fr))] items-start"> <div className="px-[0.8rem] grid gap-5 sm:gap-8 [grid-template-columns:repeat(auto-fill,minmax(min(100%,22rem),1fr))] sm:[grid-template-columns:repeat(auto-fill,minmax(min(100%,28rem),1fr))] items-start">
{group.items.map((vo, index) => ( {group.items.map((vo, index) => (
<div key={vo.id || index}> <div key={vo.id || index}>
<ExtensionCardComponent <ExtensionCardComponent
@@ -14,10 +14,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
ToggleGroup,
ToggleGroupItem,
} from '@/components/ui/toggle-group';
import { import {
Search, Search,
Wrench, Wrench,
@@ -153,39 +150,54 @@ function MarketPageContent({
}); });
}, []); }, []);
const transformMCPToVO = useCallback((mcp: any): PluginMarketCardVO => { const transformMCPToVO = useCallback(
return new PluginMarketCardVO({ (mcp: any): PluginMarketCardVO => {
pluginId: mcp.author + ' / ' + mcp.name, return new PluginMarketCardVO({
author: mcp.author, pluginId: mcp.author + ' / ' + mcp.name,
pluginName: mcp.name, author: mcp.author,
label: extractI18nObject(mcp.label), pluginName: mcp.name,
description: extractI18nObject(mcp.description) || t('market.noDescription'), label: extractI18nObject(mcp.label),
installCount: mcp.install_count || 0, description:
iconURL: mcp.icon || getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name), extractI18nObject(mcp.description) || t('market.noDescription'),
githubURL: mcp.repository, installCount: mcp.install_count || 0,
version: mcp.latest_version, iconURL:
components: mcp.components || {}, mcp.icon ||
tags: mcp.tags || [], getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name),
type: 'mcp', githubURL: mcp.repository,
}); version: mcp.latest_version,
}, [t]); components: mcp.components || {},
tags: mcp.tags || [],
type: 'mcp',
});
},
[t],
);
const transformSkillToVO = useCallback((skill: any): PluginMarketCardVO => { const transformSkillToVO = useCallback(
return new PluginMarketCardVO({ (skill: any): PluginMarketCardVO => {
pluginId: skill.author + ' / ' + skill.name, return new PluginMarketCardVO({
author: skill.author, pluginId: skill.author + ' / ' + skill.name,
pluginName: skill.name, author: skill.author,
label: extractI18nObject(skill.label), pluginName: skill.name,
description: extractI18nObject(skill.description) || t('market.noDescription'), label: extractI18nObject(skill.label),
installCount: skill.install_count || 0, description:
iconURL: skill.icon || getCloudServiceClientSync().getPluginIconURL(skill.author, skill.name), extractI18nObject(skill.description) || t('market.noDescription'),
githubURL: skill.repository, installCount: skill.install_count || 0,
version: skill.latest_version, iconURL:
components: skill.components || {}, skill.icon ||
tags: skill.tags || [], getCloudServiceClientSync().getPluginIconURL(
type: 'skill', skill.author,
}); skill.name,
}, [t]); ),
githubURL: skill.repository,
version: skill.latest_version,
components: skill.components || {},
tags: skill.tags || [],
type: 'skill',
});
},
[t],
);
// 获取插件列表 // 获取插件列表
const fetchPlugins = useCallback( const fetchPlugins = useCallback(
@@ -212,20 +224,24 @@ function MarketPageContent({
let skillsTotal = 0; let skillsTotal = 0;
try { try {
const pluginsResponse = await getCloudServiceClientSync().searchMarketplacePlugins( const pluginsResponse =
query, await getCloudServiceClientSync().searchMarketplacePlugins(
page, query,
pageSize, page,
sortBy, pageSize,
sortOrder, sortBy,
undefined, sortOrder,
selectedTags.length > 0 ? selectedTags : undefined, undefined,
'plugin', selectedTags.length > 0 ? selectedTags : undefined,
); 'plugin',
);
pluginsResult = pluginsResponse.plugins pluginsResult = pluginsResponse.plugins
.filter((plugin) => { .filter((plugin) => {
const keys = Object.keys(plugin.components || {}); const keys = Object.keys(plugin.components || {});
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')); return !(
keys.length > 0 &&
keys.every((k) => k === 'KnowledgeRetriever')
);
}) })
.map(transformToVO); .map(transformToVO);
pluginsTotal = pluginsResponse.total || 0; pluginsTotal = pluginsResponse.total || 0;
@@ -234,16 +250,17 @@ function MarketPageContent({
} }
try { try {
const mcpsResponse = await getCloudServiceClientSync().searchMarketplacePlugins( const mcpsResponse =
query, await getCloudServiceClientSync().searchMarketplacePlugins(
page, query,
pageSize, page,
sortBy, pageSize,
sortOrder, sortBy,
undefined, sortOrder,
selectedTags.length > 0 ? selectedTags : undefined, undefined,
'mcp', selectedTags.length > 0 ? selectedTags : undefined,
); 'mcp',
);
mcpsResult = (mcpsResponse.plugins || []).map(transformMCPToVO); mcpsResult = (mcpsResponse.plugins || []).map(transformMCPToVO);
mcpsTotal = mcpsResponse.total || 0; mcpsTotal = mcpsResponse.total || 0;
} catch (e) { } catch (e) {
@@ -251,17 +268,20 @@ function MarketPageContent({
} }
try { try {
const skillsResponse = await getCloudServiceClientSync().searchMarketplacePlugins( const skillsResponse =
query, await getCloudServiceClientSync().searchMarketplacePlugins(
page, query,
pageSize, page,
sortBy, pageSize,
sortOrder, sortBy,
undefined, sortOrder,
selectedTags.length > 0 ? selectedTags : undefined, undefined,
'skill', selectedTags.length > 0 ? selectedTags : undefined,
'skill',
);
skillsResult = (skillsResponse.plugins || []).map(
transformSkillToVO,
); );
skillsResult = (skillsResponse.plugins || []).map(transformSkillToVO);
skillsTotal = skillsResponse.total || 0; skillsTotal = skillsResponse.total || 0;
} catch (e) { } catch (e) {
console.warn('Failed to fetch skills:', e); console.warn('Failed to fetch skills:', e);
@@ -270,22 +290,25 @@ function MarketPageContent({
newPlugins = [...pluginsResult, ...mcpsResult, ...skillsResult]; newPlugins = [...pluginsResult, ...mcpsResult, ...skillsResult];
total = pluginsTotal + mcpsTotal + skillsTotal; total = pluginsTotal + mcpsTotal + skillsTotal;
} else { } else {
const response = await getCloudServiceClientSync().searchMarketplacePlugins( const response =
query, await getCloudServiceClientSync().searchMarketplacePlugins(
page, query,
pageSize, page,
sortBy, pageSize,
sortOrder, sortBy,
undefined, sortOrder,
selectedTags.length > 0 ? selectedTags : undefined, undefined,
typeFilter === 'all' ? undefined : typeFilter, selectedTags.length > 0 ? selectedTags : undefined,
); typeFilter === 'all' ? undefined : typeFilter,
);
const data: ApiRespMarketplacePlugins = response; const data: ApiRespMarketplacePlugins = response;
newPlugins = data.plugins newPlugins = data.plugins
.filter((plugin) => { .filter((plugin) => {
const keys = Object.keys(plugin.components || {}); const keys = Object.keys(plugin.components || {});
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')); return !(
keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')
);
}) })
.map(transformToVO); .map(transformToVO);
total = data.total; total = data.total;
@@ -300,7 +323,9 @@ function MarketPageContent({
setTotal(total); setTotal(total);
setHasMore( setHasMore(
newPlugins.length > 0 && newPlugins.length > 0 &&
(reset || page === 1 ? newPlugins.length : plugins.length + newPlugins.length) < total, (reset || page === 1
? newPlugins.length
: plugins.length + newPlugins.length) < total,
); );
} catch (error) { } catch (error) {
console.error('Failed to fetch plugins:', error); console.error('Failed to fetch plugins:', error);
@@ -455,12 +480,21 @@ function MarketPageContent({
const pluginV4: PluginV4 = { const pluginV4: PluginV4 = {
id: 0, id: 0,
plugin_id: `${cardVO.author}/${cardVO.pluginName}`, plugin_id: `${cardVO.author}/${cardVO.pluginName}`,
mcp_id: cardVO.type === 'mcp' ? `${cardVO.author}/${cardVO.pluginName}` : undefined, mcp_id:
skill_id: cardVO.type === 'skill' ? `${cardVO.author}/${cardVO.pluginName}` : undefined, cardVO.type === 'mcp'
? `${cardVO.author}/${cardVO.pluginName}`
: undefined,
skill_id:
cardVO.type === 'skill'
? `${cardVO.author}/${cardVO.pluginName}`
: undefined,
author: cardVO.author, author: cardVO.author,
name: cardVO.pluginName, name: cardVO.pluginName,
label: { en_US: cardVO.label, zh_Hans: cardVO.label }, label: { en_US: cardVO.label, zh_Hans: cardVO.label },
description: { en_US: cardVO.description, zh_Hans: cardVO.description }, description: {
en_US: cardVO.description,
zh_Hans: cardVO.description,
},
icon: cardVO.iconURL, icon: cardVO.iconURL,
repository: cardVO.githubURL, repository: cardVO.githubURL,
tags: cardVO.tags || [], tags: cardVO.tags || [],
@@ -482,7 +516,10 @@ function MarketPageContent({
cardVO.pluginName, cardVO.pluginName,
); );
if (!response?.plugin) { if (!response?.plugin) {
console.error('Failed to install plugin: plugin not found', { author: cardVO.author, pluginName: cardVO.pluginName }); console.error('Failed to install plugin: plugin not found', {
author: cardVO.author,
pluginName: cardVO.pluginName,
});
toast.error(t('market.installFailed')); toast.error(t('market.installFailed'));
return; return;
} }
@@ -601,7 +638,7 @@ function MarketPageContent({
/> />
</div> </div>
{headerActions && ( {headerActions && (
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-wrap lg:flex-nowrap lg:flex-shrink-0">
{headerActions} {headerActions}
</div> </div>
)} )}
@@ -631,7 +668,9 @@ function MarketPageContent({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className="relative flex-shrink-0"> <Button variant="outline" className="relative flex-shrink-0">
<SlidersHorizontal className="h-4 w-4" /> <SlidersHorizontal className="h-4 w-4" />
<span className="hidden sm:inline">{t('market.filters.more')}</span> <span className="hidden sm:inline">
{t('market.filters.more')}
</span>
{activeAdvancedFilters > 0 && ( {activeAdvancedFilters > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none text-primary-foreground"> <span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none text-primary-foreground">
{activeAdvancedFilters} {activeAdvancedFilters}
@@ -641,7 +680,9 @@ function MarketPageContent({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-[320px] space-y-4"> <PopoverContent align="end" className="w-[320px] space-y-4">
<div> <div>
<div className="text-sm font-medium">{t('market.filters.advancedTitle')}</div> <div className="text-sm font-medium">
{t('market.filters.advancedTitle')}
</div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{t('market.filters.advancedDescription')} {t('market.filters.advancedDescription')}
</div> </div>
@@ -735,16 +776,15 @@ function MarketPageContent({
className="flex-1 overflow-y-auto px-3 sm:px-4" className="flex-1 overflow-y-auto px-3 sm:px-4"
> >
{/* Recommendation Lists */} {/* Recommendation Lists */}
{!searchQuery && {!searchQuery && selectedTags.length === 0 && (
selectedTags.length === 0 && ( <div className="pt-4">
<div className="pt-4"> <RecommendationLists
<RecommendationLists lists={recommendationLists}
lists={recommendationLists} tagNames={tagNames}
tagNames={tagNames} onInstall={handleInstallPlugin}
onInstall={handleInstallPlugin} />
/> </div>
</div> )}
)}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -823,7 +863,10 @@ export default function MarketPage({
</div> </div>
} }
> >
<MarketPageContent installPlugin={installPlugin} headerActions={headerActions} /> <MarketPageContent
installPlugin={installPlugin}
headerActions={headerActions}
/>
</Suspense> </Suspense>
); );
} }
+28 -21
View File
@@ -537,22 +537,24 @@ function PluginListView() {
/> />
{/* Header bar with filter tabs, debug info, and task queue */} {/* Header bar with filter tabs, debug info, and task queue */}
<div className="flex flex-row justify-between items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2"> <div className="flex flex-col md:flex-row md:justify-between md:items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
<Tabs <div className="overflow-x-auto -mx-1 px-1">
value={filterType} <Tabs
onValueChange={(value) => setFilterType(value as FilterType)} value={filterType}
> onValueChange={(value) => setFilterType(value as FilterType)}
<TabsList> >
{FilterOptions.map((option) => ( <TabsList>
<TabsTrigger key={option.value} value={option.value}> {FilterOptions.map((option) => (
{option.icon && <option.icon className="size-4" />} <TabsTrigger key={option.value} value={option.value}>
{t(option.labelKey)} {option.icon && <option.icon className="size-4" />}
</TabsTrigger> {t(option.labelKey)}
))} </TabsTrigger>
</TabsList> ))}
</Tabs> </TabsList>
<div className="flex flex-row items-center gap-2"> </Tabs>
<div className="flex items-center gap-2 px-2"> </div>
<div className="flex flex-row items-center gap-2 flex-wrap">
<div className="flex items-center gap-2 px-1 sm:px-2">
<Switch <Switch
id="group-by-type" id="group-by-type"
checked={groupByType} checked={groupByType}
@@ -572,14 +574,19 @@ function PluginListView() {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="px-4 py-4 cursor-pointer" className="px-3 sm:px-4 py-4 cursor-pointer"
onClick={handleShowDebugInfo} onClick={handleShowDebugInfo}
> >
<Code className="w-4 h-4 mr-2" /> <Code className="w-4 h-4 sm:mr-2" />
{t('plugins.debugInfo')} <span className="hidden sm:inline">
{t('plugins.debugInfo')}
</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[380px]" align="end"> <PopoverContent
className="w-[calc(100vw-2rem)] max-w-[380px]"
align="end"
>
<div className="space-y-3"> <div className="space-y-3">
{/* Header with icon and title */} {/* Header with icon and title */}
<div className="flex items-center gap-2 pb-2 border-b"> <div className="flex items-center gap-2 pb-2 border-b">
@@ -597,7 +604,7 @@ function PluginListView() {
<Input <Input
value={debugInfo?.debug_url || ''} value={debugInfo?.debug_url || ''}
readOnly readOnly
className="w-[220px] font-mono text-xs h-8" className="flex-1 min-w-0 font-mono text-xs h-8"
/> />
<Button <Button
variant="ghost" variant="ghost"
+9 -2
View File
@@ -14,8 +14,15 @@
padding-top: 2rem; padding-top: 2rem;
padding-bottom: 2rem; padding-bottom: 2rem;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
gap: 2rem; gap: 1.25rem;
justify-items: stretch; justify-items: stretch;
align-items: start; align-items: start;
} }
@media (min-width: 640px) {
.pluginListContainer {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 28rem), 1fr));
gap: 2rem;
}
}