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

View File

@@ -56,7 +56,9 @@ function AddExtensionContent() {
} = usePluginInstallTasks();
const [modalOpen, setModalOpen] = useState(false);
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] =
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
const [installError, setInstallError] = useState<string | null>(null);
@@ -120,29 +122,38 @@ function AddExtensionContent() {
<>
<Button
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')}
>
<PlusIcon className="w-4 h-4" />
{t('mcp.addMCPServer')}
<span className="whitespace-nowrap">{t('mcp.addMCPServer')}</span>
</Button>
<DropdownMenu>
<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" />
{t('skills.addSkill')}
<ChevronDownIcon className="ml-2 w-4 h-4" />
<span className="whitespace-nowrap">{t('skills.addSkill')}</span>
<ChevronDownIcon className="ml-1 sm:ml-2 w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate('/home/skills?action=create')}>
<DropdownMenuItem
onClick={() => navigate('/home/skills?action=create')}
>
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/home/skills?action=upload')}>
<DropdownMenuItem
onClick={() => navigate('/home/skills?action=upload')}
>
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/home/skills?action=github')}>
<DropdownMenuItem
onClick={() => navigate('/home/skills?action=github')}
>
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -150,17 +161,24 @@ function AddExtensionContent() {
<DropdownMenu>
<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" />
{t('plugins.newPlugin')}
<ChevronDownIcon className="ml-2 w-4 h-4" />
<span className="whitespace-nowrap">{t('plugins.newPlugin')}</span>
<ChevronDownIcon className="ml-1 sm:ml-2 w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate('/home/add-plugin?action=github')}>
<DropdownMenuItem
onClick={() => navigate('/home/add-plugin?action=github')}
>
{t('plugins.installFromGithub')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/home/add-plugin?action=upload')}>
<DropdownMenuItem
onClick={() => navigate('/home/add-plugin?action=upload')}
>
{t('plugins.uploadLocal')}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -172,7 +190,10 @@ function AddExtensionContent() {
<>
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto">
<MarketPage installPlugin={handleInstallPlugin} headerActions={extensionActions} />
<MarketPage
installPlugin={handleInstallPlugin}
headerActions={extensionActions}
/>
</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>
<DialogTitle className="flex items-center gap-4">
<Download className="size-6" />
@@ -238,4 +259,4 @@ function AddExtensionContent() {
</Dialog>
</>
);
}
}

View File

@@ -462,7 +462,7 @@ const PluginInstalledComponent = forwardRef<
({group.items.length})
</span>
</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) => (
<div key={vo.id || index}>
<ExtensionCardComponent

View File

@@ -14,10 +14,7 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import {
ToggleGroup,
ToggleGroupItem,
} from '@/components/ui/toggle-group';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Search,
Wrench,
@@ -153,39 +150,54 @@ function MarketPageContent({
});
}, []);
const transformMCPToVO = useCallback((mcp: any): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: mcp.author + ' / ' + mcp.name,
author: mcp.author,
pluginName: mcp.name,
label: extractI18nObject(mcp.label),
description: extractI18nObject(mcp.description) || t('market.noDescription'),
installCount: mcp.install_count || 0,
iconURL: mcp.icon || getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name),
githubURL: mcp.repository,
version: mcp.latest_version,
components: mcp.components || {},
tags: mcp.tags || [],
type: 'mcp',
});
}, [t]);
const transformMCPToVO = useCallback(
(mcp: any): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: mcp.author + ' / ' + mcp.name,
author: mcp.author,
pluginName: mcp.name,
label: extractI18nObject(mcp.label),
description:
extractI18nObject(mcp.description) || t('market.noDescription'),
installCount: mcp.install_count || 0,
iconURL:
mcp.icon ||
getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name),
githubURL: mcp.repository,
version: mcp.latest_version,
components: mcp.components || {},
tags: mcp.tags || [],
type: 'mcp',
});
},
[t],
);
const transformSkillToVO = useCallback((skill: any): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: skill.author + ' / ' + skill.name,
author: skill.author,
pluginName: skill.name,
label: extractI18nObject(skill.label),
description: extractI18nObject(skill.description) || t('market.noDescription'),
installCount: skill.install_count || 0,
iconURL: skill.icon || getCloudServiceClientSync().getPluginIconURL(skill.author, skill.name),
githubURL: skill.repository,
version: skill.latest_version,
components: skill.components || {},
tags: skill.tags || [],
type: 'skill',
});
}, [t]);
const transformSkillToVO = useCallback(
(skill: any): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: skill.author + ' / ' + skill.name,
author: skill.author,
pluginName: skill.name,
label: extractI18nObject(skill.label),
description:
extractI18nObject(skill.description) || t('market.noDescription'),
installCount: skill.install_count || 0,
iconURL:
skill.icon ||
getCloudServiceClientSync().getPluginIconURL(
skill.author,
skill.name,
),
githubURL: skill.repository,
version: skill.latest_version,
components: skill.components || {},
tags: skill.tags || [],
type: 'skill',
});
},
[t],
);
// 获取插件列表
const fetchPlugins = useCallback(
@@ -212,20 +224,24 @@ function MarketPageContent({
let skillsTotal = 0;
try {
const pluginsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
undefined,
selectedTags.length > 0 ? selectedTags : undefined,
'plugin',
);
const pluginsResponse =
await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
undefined,
selectedTags.length > 0 ? selectedTags : undefined,
'plugin',
);
pluginsResult = pluginsResponse.plugins
.filter((plugin) => {
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);
pluginsTotal = pluginsResponse.total || 0;
@@ -234,16 +250,17 @@ function MarketPageContent({
}
try {
const mcpsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
undefined,
selectedTags.length > 0 ? selectedTags : undefined,
'mcp',
);
const mcpsResponse =
await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
undefined,
selectedTags.length > 0 ? selectedTags : undefined,
'mcp',
);
mcpsResult = (mcpsResponse.plugins || []).map(transformMCPToVO);
mcpsTotal = mcpsResponse.total || 0;
} catch (e) {
@@ -251,17 +268,20 @@ function MarketPageContent({
}
try {
const skillsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
undefined,
selectedTags.length > 0 ? selectedTags : undefined,
'skill',
const skillsResponse =
await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
undefined,
selectedTags.length > 0 ? selectedTags : undefined,
'skill',
);
skillsResult = (skillsResponse.plugins || []).map(
transformSkillToVO,
);
skillsResult = (skillsResponse.plugins || []).map(transformSkillToVO);
skillsTotal = skillsResponse.total || 0;
} catch (e) {
console.warn('Failed to fetch skills:', e);
@@ -270,22 +290,25 @@ function MarketPageContent({
newPlugins = [...pluginsResult, ...mcpsResult, ...skillsResult];
total = pluginsTotal + mcpsTotal + skillsTotal;
} else {
const response = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
undefined,
selectedTags.length > 0 ? selectedTags : undefined,
typeFilter === 'all' ? undefined : typeFilter,
);
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
undefined,
selectedTags.length > 0 ? selectedTags : undefined,
typeFilter === 'all' ? undefined : typeFilter,
);
const data: ApiRespMarketplacePlugins = response;
newPlugins = data.plugins
.filter((plugin) => {
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);
total = data.total;
@@ -300,7 +323,9 @@ function MarketPageContent({
setTotal(total);
setHasMore(
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) {
console.error('Failed to fetch plugins:', error);
@@ -455,12 +480,21 @@ function MarketPageContent({
const pluginV4: PluginV4 = {
id: 0,
plugin_id: `${cardVO.author}/${cardVO.pluginName}`,
mcp_id: cardVO.type === 'mcp' ? `${cardVO.author}/${cardVO.pluginName}` : undefined,
skill_id: cardVO.type === 'skill' ? `${cardVO.author}/${cardVO.pluginName}` : undefined,
mcp_id:
cardVO.type === 'mcp'
? `${cardVO.author}/${cardVO.pluginName}`
: undefined,
skill_id:
cardVO.type === 'skill'
? `${cardVO.author}/${cardVO.pluginName}`
: undefined,
author: cardVO.author,
name: cardVO.pluginName,
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,
repository: cardVO.githubURL,
tags: cardVO.tags || [],
@@ -482,7 +516,10 @@ function MarketPageContent({
cardVO.pluginName,
);
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'));
return;
}
@@ -601,7 +638,7 @@ function MarketPageContent({
/>
</div>
{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}
</div>
)}
@@ -631,7 +668,9 @@ function MarketPageContent({
<PopoverTrigger asChild>
<Button variant="outline" className="relative flex-shrink-0">
<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 && (
<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}
@@ -641,7 +680,9 @@ function MarketPageContent({
</PopoverTrigger>
<PopoverContent align="end" className="w-[320px] space-y-4">
<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">
{t('market.filters.advancedDescription')}
</div>
@@ -735,16 +776,15 @@ function MarketPageContent({
className="flex-1 overflow-y-auto px-3 sm:px-4"
>
{/* Recommendation Lists */}
{!searchQuery &&
selectedTags.length === 0 && (
<div className="pt-4">
<RecommendationLists
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
/>
</div>
)}
{!searchQuery && selectedTags.length === 0 && (
<div className="pt-4">
<RecommendationLists
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
/>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
@@ -823,7 +863,10 @@ export default function MarketPage({
</div>
}
>
<MarketPageContent installPlugin={installPlugin} headerActions={headerActions} />
<MarketPageContent
installPlugin={installPlugin}
headerActions={headerActions}
/>
</Suspense>
);
}

View File

@@ -537,22 +537,24 @@ function PluginListView() {
/>
{/* 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">
<Tabs
value={filterType}
onValueChange={(value) => setFilterType(value as FilterType)}
>
<TabsList>
{FilterOptions.map((option) => (
<TabsTrigger key={option.value} value={option.value}>
{option.icon && <option.icon className="size-4" />}
{t(option.labelKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex flex-row items-center gap-2">
<div className="flex items-center gap-2 px-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">
<div className="overflow-x-auto -mx-1 px-1">
<Tabs
value={filterType}
onValueChange={(value) => setFilterType(value as FilterType)}
>
<TabsList>
{FilterOptions.map((option) => (
<TabsTrigger key={option.value} value={option.value}>
{option.icon && <option.icon className="size-4" />}
{t(option.labelKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</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
id="group-by-type"
checked={groupByType}
@@ -572,14 +574,19 @@ function PluginListView() {
<PopoverTrigger asChild>
<Button
variant="outline"
className="px-4 py-4 cursor-pointer"
className="px-3 sm:px-4 py-4 cursor-pointer"
onClick={handleShowDebugInfo}
>
<Code className="w-4 h-4 mr-2" />
{t('plugins.debugInfo')}
<Code className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">
{t('plugins.debugInfo')}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[380px]" align="end">
<PopoverContent
className="w-[calc(100vw-2rem)] max-w-[380px]"
align="end"
>
<div className="space-y-3">
{/* Header with icon and title */}
<div className="flex items-center gap-2 pb-2 border-b">
@@ -597,7 +604,7 @@ function PluginListView() {
<Input
value={debugInfo?.debug_url || ''}
readOnly
className="w-[220px] font-mono text-xs h-8"
className="flex-1 min-w-0 font-mono text-xs h-8"
/>
<Button
variant="ghost"

View File

@@ -14,8 +14,15 @@
padding-top: 2rem;
padding-bottom: 2rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));
gap: 2rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
gap: 1.25rem;
justify-items: stretch;
align-items: start;
}
@media (min-width: 640px) {
.pluginListContainer {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 28rem), 1fr));
gap: 2rem;
}
}