fix(web): align main nav defaults

Default integrations to the model provider section and route the main nav entry there.

Hide cloud-only workspace credits and upgrade actions outside cloud edition.

Add the repo-local karpathy-guidelines skill.
This commit is contained in:
Jingyi-Dify 2026-05-08 15:03:23 -07:00
parent 1e0fe6c939
commit baaae00fa8
7 changed files with 185 additions and 24 deletions

View File

@ -0,0 +1,33 @@
---
name: karpathy-guidelines
description: Lightweight coding guardrails for making focused, simple, and verifiable changes in this repo. Use for all coding work.
---
# Karpathy Guidelines
Use this skill whenever you touch code in this repository.
## Principles
- Keep the change small and directly tied to the user request.
- Prefer the simplest implementation that fits the existing codebase.
- Read the nearby code first, then match its patterns.
- Avoid unrelated refactors, broad rewrites, or style churn.
- Preserve existing behavior unless the user explicitly asked to change it.
- Treat regressions as a signal to narrow the change, not to add workaround layers.
## Workflow
1. Inspect the current implementation and tests around the change.
2. Make the smallest coherent edit.
3. Add or update focused tests when the behavior changes or the risk is non-trivial.
4. Run the narrowest relevant verification first.
5. Report exactly what was verified and anything left unverified.
## Review Checklist
- Does this change solve the stated problem without expanding scope?
- Did it preserve existing route/component/data-flow semantics?
- Are new abstractions justified by real complexity?
- Are tests focused on the behavior that could regress?
- Are unrelated files and generated artifacts left alone?

View File

@ -74,6 +74,14 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
const mockPush = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
@ -198,7 +206,7 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/explore/apps')
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/tools')
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/tools?section=provider')
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins')
})

View File

@ -0,0 +1,110 @@
import type { AppContextValue } from '@/context/app-context'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import { screen } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import WorkspaceCard from '../workspace-card'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: false,
}
})
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/context/workspace-context', () => ({
useWorkspacesContext: vi.fn(),
}))
vi.mock('@/service/common', () => ({
switchWorkspace: vi.fn(),
}))
const appContextValue: AppContextValue = {
userProfile: {
id: 'user-1',
name: 'Evan Z',
email: 'evan@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
mutateUserProfile: vi.fn(),
currentWorkspace: {
id: 'workspace-1',
name: 'Solar Studio',
plan: Plan.sandbox,
status: 'normal',
created_at: 0,
role: 'owner',
providers: [],
trial_credits: 10000,
trial_credits_used: 2500,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: {
current_env: 'testing',
current_version: '1.0.0',
latest_version: '1.0.0',
release_date: '',
release_notes: '',
version: '1.0.0',
can_auto_update: false,
},
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
describe('WorkspaceCard', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue(appContextValue)
vi.mocked(useProviderContext).mockReturnValue({
enableBilling: true,
isEducationAccount: false,
isFetchedPlan: true,
plan: { type: Plan.sandbox },
} as ProviderContextState)
vi.mocked(useModalContext).mockReturnValue({
setShowPricingModal: vi.fn(),
setShowAccountSettingModal: vi.fn(),
} as unknown as ModalContextState)
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: 'workspace-1', name: 'Solar Studio', plan: Plan.sandbox, status: 'normal', created_at: 0, current: true },
],
})
})
it('hides cloud-only credits and upgrade actions outside cloud edition', () => {
renderWithSystemFeatures(<WorkspaceCard />)
expect(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /common\.mainNav\.workspace\.credits/ })).not.toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
})
})

View File

@ -7,6 +7,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -113,34 +114,36 @@ const WorkspaceCard = () => {
</div>
<span aria-hidden className="i-ri-expand-up-down-line h-4 w-4 shrink-0 text-text-tertiary" />
</button>
<div className="flex items-center justify-center gap-1.5 border-t border-divider-subtle py-2 pr-2.5 pl-2">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-0.5 px-1 text-left text-text-tertiary transition-colors hover:text-text-secondary"
aria-label={t('mainNav.workspace.credits', { ns: 'common', count: formattedCredits })}
onClick={(e) => {
e.stopPropagation()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
<span className="i-custom-vender-main-nav-credits h-3 w-3 shrink-0" aria-hidden />
<span className="truncate system-xs-medium" title={formattedCredits}>{formattedCredits}</span>
<span className="shrink-0 system-xs-regular">{t('mainNav.workspace.creditsUnit', { ns: 'common' })}</span>
</button>
{enableBilling && (
{IS_CLOUD_EDITION && (
<div className="flex items-center justify-center gap-1.5 border-t border-divider-subtle py-2 pr-2.5 pl-2">
<button
type="button"
className="max-w-[120px] shrink-0 truncate px-1 system-xs-semibold-uppercase text-saas-dify-blue-accessible transition-colors hover:text-saas-dify-blue-static-hover"
title={t('upgradeBtn.encourageShort', { ns: 'billing' })}
className="flex min-w-0 flex-1 items-center gap-0.5 px-1 text-left text-text-tertiary transition-colors hover:text-text-secondary"
aria-label={t('mainNav.workspace.credits', { ns: 'common', count: formattedCredits })}
onClick={(e) => {
e.stopPropagation()
handlePlanClick()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
<span className="i-custom-vender-main-nav-credits h-3 w-3 shrink-0" aria-hidden />
<span className="truncate system-xs-medium" title={formattedCredits}>{formattedCredits}</span>
<span className="shrink-0 system-xs-regular">{t('mainNav.workspace.creditsUnit', { ns: 'common' })}</span>
</button>
)}
</div>
{enableBilling && (
<button
type="button"
className="max-w-[120px] shrink-0 truncate px-1 system-xs-semibold-uppercase text-saas-dify-blue-accessible transition-colors hover:text-saas-dify-blue-static-hover"
title={t('upgradeBtn.encourageShort', { ns: 'billing' })}
onClick={(e) => {
e.stopPropagation()
handlePlanClick()
}}
>
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</button>
)}
</div>
)}
</div>
{open && (
<div className="absolute top-0 right-0 left-0 z-20 flex flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">

View File

@ -53,7 +53,7 @@ const MainNav = ({
activeIcon: 'i-custom-vender-main-nav-knowledge-active',
},
{
href: '/tools',
href: '/tools?section=provider',
label: t('mainNav.integrations', { ns: 'common' }),
active: path => path.startsWith('/tools'),
icon: 'i-custom-vender-main-nav-integrations',

View File

@ -45,6 +45,13 @@ describe('IntegrationsPage', () => {
vi.clearAllMocks()
})
it('defaults to the model provider section when no query is provided', () => {
renderIntegrationsPage()
expect(screen.getByTestId('model-provider-page')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.provider')).toHaveLength(2)
})
it('renders the model provider section from the section query', () => {
renderIntegrationsPage({ section: 'provider' })

View File

@ -129,7 +129,7 @@ export default function IntegrationsPage() {
const { t } = useTranslation()
const [sectionParam] = useQueryState('section', parseAsIntegrationSection)
const [categoryParam] = useQueryState('category', parseAsToolCategory)
const section = sectionParam ?? sectionByToolCategory[categoryParam ?? 'builtin']
const section = sectionParam ?? (categoryParam ? sectionByToolCategory[categoryParam] : 'provider')
const [providerSearchText, setProviderSearchText] = useState('')
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const providerItem = useMemo<NavItem>(() => ({