Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2

This commit is contained in:
yyh
2026-05-29 22:17:43 +08:00
11 changed files with 128 additions and 83 deletions

View File

@ -21,7 +21,7 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
return <FullScreenLoading />
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
<div className="flex min-h-0 w-full flex-1 flex-col items-center justify-center gap-4 bg-background-body">
<div className="system-sm-regular text-text-tertiary">
{t('errorBoundary.message')}
</div>

View File

@ -42,7 +42,7 @@ const AppCard = ({
}
return (
<div className="group relative col-span-1 flex h-[142px] cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3">
<div className="group relative col-span-1 flex h-35.5 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3">
<div className="flex shrink-0 items-center gap-3 px-4 pt-4 pb-2">
<div className="relative shrink-0">
<AppIcon

View File

@ -73,23 +73,26 @@ export function ExploreHeaderSkeleton() {
function ExploreAppCardSkeleton() {
return (
<div className="col-span-1 flex h-[142px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3">
<SkeletonContainer className="h-full px-4 pt-4">
<SkeletonRow>
<div className="flex shrink-0 items-center gap-3 px-4 pt-4 pb-2">
<div className="relative shrink-0">
<SkeletonRectangle className="size-10 shrink-0 animate-pulse rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<SkeletonRectangle className="h-4 w-2/3 animate-pulse" />
<SkeletonRectangle className="h-3 w-1/3 animate-pulse" />
</div>
</SkeletonRow>
<div className="mt-3 flex flex-col gap-1">
<SkeletonRectangle className="h-3 w-full animate-pulse" />
<SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
</div>
<div className="mt-auto flex gap-1 pb-1">
<SkeletonRectangle className="h-5 w-20 animate-pulse rounded-[5px]" />
<SkeletonRectangle className="h-5 w-16 animate-pulse rounded-[5px]" />
<div className="flex w-0 grow flex-col gap-1 py-px">
<SkeletonRectangle className="my-0 h-4 w-3/5 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-16 animate-pulse" />
</div>
</SkeletonContainer>
</div>
<div className="flex shrink-0 items-start px-4 py-1">
<div className="flex flex-1 flex-col gap-1">
<SkeletonRectangle className="my-0 h-3 w-full animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-4/5 animate-pulse" />
</div>
</div>
<div className="relative flex h-[26px] w-full shrink-0 flex-col gap-2 overflow-hidden px-3">
<div className="flex w-full shrink-0 items-center gap-1 rounded-lg p-1">
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-[5px]" />
</div>
</div>
</div>
)
}

View File

@ -155,11 +155,10 @@ describe('Banner', () => {
render(<Banner />)
const loadingWrapper = document.querySelector('[style*="min-height"]')
expect(loadingWrapper).toBeInTheDocument()
expect(screen.getByRole('status', { name: 'loading' })).toBeInTheDocument()
})
it('shows loading indicator with correct minimum height', () => {
it('matches the loaded greeting card shell while loading', () => {
mockUseGetBanners.mockReturnValue({
data: null,
isLoading: true,
@ -168,39 +167,42 @@ describe('Banner', () => {
render(<Banner />)
const loadingWrapper = document.querySelector('[style*="min-height: 168px"]')
expect(loadingWrapper).toBeInTheDocument()
const loadingWrapper = screen.getByRole('status', { name: 'loading' })
expect(loadingWrapper).toHaveClass('rounded-[24px]', 'bg-background-default-dodge')
expect(loadingWrapper.querySelector('.px-8.pt-8.pb-8')).toBeInTheDocument()
})
})
describe('error state', () => {
it('returns null when isError is true', () => {
it('renders the greeting shell without slider when isError is true', () => {
mockUseGetBanners.mockReturnValue({
data: null,
isLoading: false,
isError: true,
})
const { container } = render(<Banner />)
render(<Banner />)
expect(container.firstChild).toBeNull()
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
})
})
describe('empty state', () => {
it('returns null when banners array is empty', () => {
it('renders the greeting shell without slider when banners array is empty', () => {
mockUseGetBanners.mockReturnValue({
data: [],
isLoading: false,
isError: false,
})
const { container } = render(<Banner />)
render(<Banner />)
expect(container.firstChild).toBeNull()
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
})
it('returns null when all banners are disabled', () => {
it('renders the greeting shell without slider when all banners are disabled', () => {
mockUseGetBanners.mockReturnValue({
data: [
createMockBanner('1', 'disabled'),
@ -210,21 +212,23 @@ describe('Banner', () => {
isError: false,
})
const { container } = render(<Banner />)
render(<Banner />)
expect(container.firstChild).toBeNull()
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
})
it('returns null when data is undefined', () => {
it('renders the greeting shell without slider when data is undefined', () => {
mockUseGetBanners.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
})
const { container } = render(<Banner />)
render(<Banner />)
expect(container.firstChild).toBeNull()
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
})
})

View File

@ -6,24 +6,31 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { Carousel, useCarousel } from '@/app/components/base/carousel'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useSelector } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { useGetBanners } from '@/service/use-explore'
import Loading from '../../base/loading'
import { BannerItem } from './banner-item'
const AUTOPLAY_DELAY = 5000
const MIN_LOADING_HEIGHT = 168
const RESIZE_DEBOUNCE_DELAY = 50
const LoadingState: FC = () => (
<div
className="flex items-center justify-center rounded-[24px] bg-background-default-dodge shadow-xs"
style={{ minHeight: MIN_LOADING_HEIGHT }}
>
<Loading />
</div>
)
const LoadingState: FC = () => {
const { t } = useTranslation()
return (
<div
role="status"
aria-label={t('loading', { ns: 'common' })}
className="relative flex w-full flex-col items-start overflow-hidden rounded-[24px] bg-background-default-dodge shadow-xs"
>
<div className="flex w-full flex-col gap-1 px-8 pt-8 pb-8">
<SkeletonRectangle className="my-0 h-8 w-[360px] max-w-full animate-pulse" />
<SkeletonRectangle className="my-0 h-4 w-72 max-w-full animate-pulse" />
</div>
</div>
)
}
type BannerImpressionTrackerProps = {
banners: BannerType[]

View File

@ -284,7 +284,9 @@ describe('MainNav', () => {
renderMainNav()
expect(screen.getByText('common.environment.testing')).toBeInTheDocument()
const environmentTag = screen.getByText('common.environment.testing')
expect(environmentTag).toBeInTheDocument()
expect(environmentTag.closest('.relative.z-30')).toHaveClass('mt-auto', 'shrink-0')
})
it('does not reserve environment tag space when the environment is not shown', () => {
@ -627,10 +629,21 @@ describe('MainNav', () => {
renderMainNav()
expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.search' })).not.toBeInTheDocument()
expect(screen.queryByText('common.loading')).not.toBeInTheDocument()
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
})
it('hides the installed web apps section when no web apps are available', () => {
renderMainNav()
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.search' })).not.toBeInTheDocument()
})
it('separates pinned and unpinned installed web apps', () => {
mockInstalledApps = [
createInstalledApp({ id: 'installed-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }),

View File

@ -1,9 +1,13 @@
'use client'
import { Kbd } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import { GOTO_ANYTHING_OPEN_EVENT } from '@/app/components/goto-anything/hooks'
const MainNavSearchButton = () => {
const searchShortcut = ['Mod', 'K']
export function MainNavSearchButton() {
const { t } = useTranslation()
return (
@ -14,9 +18,11 @@ const MainNavSearchButton = () => {
onClick={() => window.dispatchEvent(new Event(GOTO_ANYTHING_OPEN_EVENT))}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
<span className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">K</span>
<Kbd className="h-[18px] min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{searchShortcut.map(key => (
<span key={key}>{formatForDisplay(key)}</span>
))}
</Kbd>
</button>
)
}
export default MainNavSearchButton

View File

@ -34,7 +34,16 @@ const appNavItemGap = 2
const appNavSeparatorHeight = 17
const virtualizationThreshold = 50
const webAppSkeletonClassName = 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none'
const webAppSkeletonWidths = ['w-24', 'w-32', 'w-20', 'w-28', 'w-36']
const webAppSkeletonWidths = ['w-24', 'w-32', 'w-28']
function WebAppsHeaderSkeleton() {
return (
<div aria-hidden="true" className="flex h-8 items-center justify-between py-1 pr-3.5 pl-4">
<div className={cn(webAppSkeletonClassName, 'h-3 w-20')} />
<div className={cn(webAppSkeletonClassName, 'size-4 rounded-md')} />
</div>
)
}
function WebAppsSkeleton() {
return (
@ -130,6 +139,9 @@ const WebAppsSection = () => {
toast.success(t('api.success', { ns: 'common' }))
}
if (!isPending && installedApps.length === 0)
return null
const renderAppNavItem = ({ id, is_pinned, uninstallable, app }: (typeof filteredApps)[number]) => (
<AppNavItem
key={id}
@ -162,33 +174,37 @@ const WebAppsSection = () => {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center justify-between py-1 pr-2.5 pl-2">
<button
type="button"
aria-expanded={appsExpanded}
className="flex min-w-0 items-center rounded-md px-2 py-1 text-left system-xs-medium-uppercase text-text-tertiary hover:text-text-secondary"
onClick={() => setAppsExpanded(value => !value)}
>
<span>{t('sidebar.webApps', { ns: 'explore' })}</span>
<span aria-hidden className={cn('i-ri-arrow-down-s-fill h-4 w-4 shrink-0 transition-transform', !appsExpanded && '-rotate-90')} />
</button>
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label={t('operation.search', { ns: 'common' })}
className={cn('flex h-6 w-6 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', searchVisible && 'bg-state-base-hover text-text-secondary')}
onClick={() => {
setAppsExpanded(true)
setSearchVisible(value => !value)
}}
>
<span className="flex size-5 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-search-line size-3.5" />
</span>
</button>
</div>
</div>
{appsExpanded && searchVisible && (
{isPending
? <WebAppsHeaderSkeleton />
: (
<div className="flex items-center justify-between py-1 pr-2.5 pl-2">
<button
type="button"
aria-expanded={appsExpanded}
className="flex min-w-0 items-center rounded-md px-2 py-1 text-left system-xs-medium-uppercase text-text-tertiary hover:text-text-secondary"
onClick={() => setAppsExpanded(value => !value)}
>
<span>{t('sidebar.webApps', { ns: 'explore' })}</span>
<span aria-hidden className={cn('i-ri-arrow-down-s-fill h-4 w-4 shrink-0 transition-transform', !appsExpanded && '-rotate-90')} />
</button>
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label={t('operation.search', { ns: 'common' })}
className={cn('flex h-6 w-6 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', searchVisible && 'bg-state-base-hover text-text-secondary')}
onClick={() => {
setAppsExpanded(true)
setSearchVisible(value => !value)
}}
>
<span className="flex size-5 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-search-line size-3.5" />
</span>
</button>
</div>
</div>
)}
{!isPending && appsExpanded && searchVisible && (
<div className="px-2 pb-2">
<SearchInput
value={searchText}
@ -212,7 +228,7 @@ const WebAppsSection = () => {
)}
{!isPending && filteredApps.length === 0 && (
<div className="px-2 py-1 system-xs-regular">
{searchText ? t('mainNav.webApps.noResults', { ns: 'common' }) : t('sidebar.noApps.title', { ns: 'explore' })}
{t('mainNav.webApps.noResults', { ns: 'common' })}
</div>
)}
{!isPending && webAppRows.length > 0 && !shouldVirtualize && (

View File

@ -20,7 +20,7 @@ import { systemFeaturesQueryOptions } from '@/service/system-features'
import AccountSection from './components/account-section'
import HelpMenu from './components/help-menu'
import MainNavLink from './components/nav-link'
import MainNavSearchButton from './components/search-button'
import { MainNavSearchButton } from './components/search-button'
import WebAppsSection from './components/web-apps-section'
import { WorkspaceCard } from './components/workspace-card'
@ -174,7 +174,7 @@ const MainNav = ({
</>
)}
{showEnvTag && (
<div className="relative z-30 px-3 pb-2">
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
<EnvNav />
</div>
)}

View File

@ -35,7 +35,5 @@ export const userProfileQueryOptions = () =>
},
}
},
staleTime: 0,
gcTime: 0,
retry: (failureCount, error) => !isLegacyBase401(error) && failureCount < 3,
})

View File

@ -75,7 +75,5 @@ export const serverUserProfileQueryOptions = () =>
},
}
},
staleTime: 0,
gcTime: 0,
retry: false,
})