Merge branch 'main' into jzh

This commit is contained in:
JzoNg 2026-04-20 16:11:07 +08:00
commit 7c8a87af05
27 changed files with 351 additions and 147 deletions

View File

@ -200,7 +200,7 @@ When assigned to test a directory/path, test **ALL content** within that path:
- ✅ **Import real project components** directly (including base components and siblings)
- ✅ **Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers
- ❌ **DO NOT mock** base components (`@/app/components/base/*`)
- ❌ **DO NOT mock** base components (`@/app/components/base/*`) or dify-ui primitives (`@langgenius/dify-ui/*`)
- ❌ **DO NOT mock** sibling/child components in the same directory
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
@ -325,12 +325,12 @@ For more detailed information, refer to:
### Reference Examples in Codebase
- `web/utils/classnames.spec.ts` - Utility function tests
- `web/app/components/base/button/index.spec.tsx` - Component tests
- `web/app/components/base/radio/__tests__/index.spec.tsx` - Component tests
- `web/__mocks__/provider-context.ts` - Mock factory example
### Project Configuration
- `web/vitest.config.ts` - Vitest configuration
- `web/vite.config.ts` - Vite/Vitest configuration
- `web/vitest.setup.ts` - Test environment setup
- `web/scripts/analyze-component.js` - Component analysis tool
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.

View File

@ -36,7 +36,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
### Integration vs Mocking
- [ ] **DO NOT mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
- [ ] **DO NOT mock base components or dify-ui primitives** (base `Loading`, `Input`, `Badge`; dify-ui `Button`, `Tooltip`, `Dialog`, etc.)
- [ ] Import real project components instead of mocking
- [ ] Only mock: API calls, complex context providers, third-party libs with side effects
- [ ] Prefer integration testing when using single spec file
@ -73,7 +73,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
### Mocks
- [ ] **DO NOT mock base components** (`@/app/components/base/*`)
- [ ] **DO NOT mock base components or dify-ui primitives** (`@/app/components/base/*` or `@langgenius/dify-ui/*`)
- [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`)
- [ ] Shared mock state reset in `beforeEach`
- [ ] i18n uses global mock (auto-loaded in `web/vitest.setup.ts`); only override locally for custom translations

View File

@ -2,29 +2,27 @@
## ⚠️ Important: What NOT to Mock
### DO NOT Mock Base Components
### DO NOT Mock Base Components or dify-ui Primitives
**Never mock components from `@/app/components/base/`** such as:
**Never mock components from `@/app/components/base/` or from `@langgenius/dify-ui/*`** such as:
- `Loading`, `Spinner`
- `Button`, `Input`, `Select`
- `Tooltip`, `Modal`, `Dropdown`
- `Icon`, `Badge`, `Tag`
- Legacy base (`@/app/components/base/*`): `Loading`, `Spinner`, `Input`, `Badge`, `Tag`
- dify-ui primitives (`@langgenius/dify-ui/*`): `Button`, `Tooltip`, `Dialog`, `Popover`, `DropdownMenu`, `ContextMenu`, `Select`, `AlertDialog`, `Toast`
**Why?**
- Base components will have their own dedicated tests
- These components have their own dedicated tests
- Mocking them creates false positives (tests pass but real integration fails)
- Using real components tests actual integration behavior
```typescript
// ❌ WRONG: Don't mock base components
// ❌ WRONG: Don't mock base components or dify-ui primitives
vi.mock('@/app/components/base/loading', () => () => <div>Loading</div>)
vi.mock('@/app/components/base/ui/button', () => ({ children }: any) => <button>{children}</button>)
vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children }: any) => <button>{children}</button> }))
// ✅ CORRECT: Import and use real base components
// ✅ CORRECT: Import and use the real components
import Loading from '@/app/components/base/loading'
import { Button } from '@/app/components/base/ui/button'
import { Button } from '@langgenius/dify-ui/button'
// They will render normally in tests
```
@ -319,7 +317,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
### ✅ DO
1. **Use real base components** - Import from `@/app/components/base/` directly
1. **Use real base components and dify-ui primitives** - Import from `@/app/components/base/` or `@langgenius/dify-ui/*` directly
1. **Use real project components** - Prefer importing over mocking
1. **Use real Zustand stores** - Set test state via `store.setState()`
1. **Reset mocks in `beforeEach`**, not `afterEach`
@ -330,7 +328,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
### ❌ DON'T
1. **Don't mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
1. **Don't mock base components or dify-ui primitives** (`Loading`, `Input`, `Button`, `Tooltip`, `Dialog`, etc.)
1. **Don't mock Zustand store modules** - Use real stores with `setState()`
1. Don't mock components you can import directly
1. Don't create overly simplified mocks that miss conditional logic
@ -342,7 +340,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
```
Need to use a component in test?
├─ Is it from @/app/components/base/*?
├─ Is it from @/app/components/base/* or @langgenius/dify-ui/*?
│ └─ YES → Import real component, DO NOT mock
├─ Is it a project component?

View File

@ -41,6 +41,10 @@ def safe_json_value(v):
return v.hex()
elif isinstance(v, memoryview):
return v.tobytes().hex()
elif isinstance(v, np.integer):
return int(v)
elif isinstance(v, np.floating):
return float(v)
elif isinstance(v, np.ndarray):
return v.tolist()
elif isinstance(v, dict):

View File

@ -6068,9 +6068,6 @@
}
},
"web/app/components/workflow/run/node.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
}

105
packages/dify-ui/README.md Normal file
View File

@ -0,0 +1,105 @@
# @langgenius/dify-ui
Shared UI primitives, design tokens, Tailwind preset, and the `cn()` utility consumed by Dify's `web/` app.
The primitives are thin, opinionated wrappers around [Base UI] headless components, styled with `cva` + `cn` and Dify design tokens.
> `private: true` — this package is consumed by `web/` via the pnpm workspace and is not published to npm. Treat the API as internal to Dify, but stable within the workspace.
## Installation
Already wired as a workspace dependency in `web/package.json`. Nothing to install.
For a new workspace consumer, add:
```jsonc
{
"dependencies": {
"@langgenius/dify-ui": "workspace:*"
}
}
```
## Imports
Always import from a **subpath export** — there is no barrel:
```ts
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported — it keeps tree-shaking trivial and makes Storybook / test coverage attribution per-primitive.
## Primitives
| Category | Subpath | Notes |
| -------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
Utilities:
- `./cn``clsx` + `tailwind-merge` wrapper. Use this for conditional class composition.
- `./tailwind-preset` — Tailwind v4 preset with Dify tokens. Apps extend it from their own `tailwind.config.ts`.
- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and base reset. Import it once from the app root.
## Overlay & portal contract
All overlay primitives (`dialog`, `alert-dialog`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually.
### Root isolation requirement
The host app **must** establish an isolated stacking context at its root so the portalled overlay layer is not clipped or re-ordered by ancestor `transform` / `filter` / `contain` styles. In the Dify web app this is done in `web/app/layout.tsx`:
```tsx
<body>
<div className="isolate h-full">{children}</div>
</body>
```
Equivalent: any root element with `isolation: isolate` in CSS. Without it, overlays can be visually clipped on Safari when a descendant creates a new stacking context.
### z-index layering
Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
| Layer | z-index | Where |
| ----------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
Rationale: during Dify's migration from legacy `portal-to-follow-elem` / `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history and the remaining legacy allowlist. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`.
### Rules
- Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated.
- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal.
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
## Development
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
- `pnpm -C packages/dify-ui type-check``tsc --noEmit` for this package only.
See `[AGENTS.md](./AGENTS.md)` for:
- Component authoring rules (one-component-per-folder, `cva` + `cn`, relative imports inside the package, subpath imports from consumers).
- Figma `--radius/`* token → Tailwind `rounded-*` class mapping.
## Not part of this package
- Application state (`jotai`, `zustand`), data fetching (`ky`, `@tanstack/react-query`, `@orpc/*`), i18n (`next-i18next` / `react-i18next`), and routing (`next`) all live in `web/`. This package has zero dependencies on them and must stay that way so it can eventually be consumed by other apps or extracted.
- Business components (chat, workflow, dataset views, etc.). Those belong in `web/app/components/...`.
[Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals
[Base UI]: https://base-ui.com/react
[Overlay & portal contract]: #overlay--portal-contract

View File

@ -1,7 +1,7 @@
'use client'
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All base/ui/* overlay primitives — z-1002
// All @langgenius/dify-ui/* overlay primitives — z-1002
// Toast stays one layer above overlays at z-1003.
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render

View File

@ -174,7 +174,7 @@ const StickyListPane = () => (
<div className="mt-1 flex items-center justify-between gap-3">
<div>
<div className="system-md-semibold text-text-primary">Operational queue</div>
<p className="mt-1 system-xs-regular text-text-secondary">The scrollbar is still the shared base/ui primitive, while the pane adds sticky structure and a viewport mask.</p>
<p className="mt-1 system-xs-regular text-text-secondary">The scrollbar is still the shared dify-ui primitive, while the pane adds sticky structure and a viewport mask.</p>
</div>
<span className="rounded-lg border border-divider-subtle bg-components-panel-bg-alt px-2.5 py-1 system-xs-medium text-text-secondary">
24 items

View File

@ -8,7 +8,7 @@ declare global {
var BASE_UI_ANIMATIONS_DISABLED: boolean | undefined
}
describe('base/ui/toast', () => {
describe('@langgenius/dify-ui/toast', () => {
beforeAll(() => {
// Base UI waits for `requestAnimationFrame` + `getAnimations().finished`
// before unmounting a toast. Fake timers can't reliably drive that path,

View File

@ -4,8 +4,9 @@
## Overlay Components (Mandatory)
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
- `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-1002` / `z-1003` layering.
- `./docs/overlay-migration.md` is the source of truth for the ongoing migration (deprecated import paths, allowlist, coexistence rules).
- In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`.
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
## Query & Mutation (Mandatory)

View File

@ -165,7 +165,7 @@ The Dify community can be found on [Discord community], where you can ask questi
[Storybook]: https://storybook.js.org
[Vite+]: https://viteplus.dev
[Vitest]: https://vitest.dev
[index.spec.tsx]: ./app/components/base/button/index.spec.tsx
[index.spec.tsx]: ./app/components/base/radio/__tests__/index.spec.tsx
[pnpm]: https://pnpm.io
[vinext]: https://github.com/cloudflare/vinext
[web/docs/test.md]: ./docs/test.md

View File

@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
@ -20,7 +19,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>

View File

@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { AppContextProvider } from '@/context/app-context-provider'
@ -14,7 +13,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>

View File

@ -1,82 +1,21 @@
'use client'
import type { FC } from 'react'
import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import type { AmplitudeInitializationOptions } from './init'
import * as React from 'react'
import { useEffect } from 'react'
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
import { ensureAmplitudeInitialized } from './init'
export type IAmplitudeProps = {
sessionReplaySampleRate?: number
}
// Map URL pathname to English page name for consistent Amplitude tracking
const getEnglishPageName = (pathname: string): string => {
// Remove leading slash and get the first segment
const segments = pathname.replace(/^\//, '').split('/')
const firstSegment = segments[0] || 'home'
const pageNameMap: Record<string, string> = {
'': 'Home',
'apps': 'Studio',
'datasets': 'Knowledge',
'explore': 'Explore',
'tools': 'Tools',
'account': 'Account',
'signin': 'Sign In',
'signup': 'Sign Up',
}
return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
}
// Enrichment plugin to override page title with English name for page view events
const pageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
return {
name: 'page-name-enrichment',
type: 'enrichment',
setup: async () => undefined,
execute: async (event: amplitude.Types.Event) => {
// Only modify page view events
if (event.event_type === '[Amplitude] Page Viewed' && event.event_properties) {
/* v8 ignore next @preserve */
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname)
}
return event
},
}
}
export type IAmplitudeProps = AmplitudeInitializationOptions
const AmplitudeProvider: FC<IAmplitudeProps> = ({
sessionReplaySampleRate = 0.5,
}) => {
useEffect(() => {
// Only enable in Saas edition with valid API key
if (!isAmplitudeEnabled)
return
// Initialize Amplitude
amplitude.init(AMPLITUDE_API_KEY, {
defaultTracking: {
sessions: true,
pageViews: true,
formInteractions: true,
fileDownloads: true,
attribution: true,
},
ensureAmplitudeInitialized({
sessionReplaySampleRate,
})
// Add page name enrichment plugin to override page title with English name
amplitude.add(pageNameEnrichmentPlugin())
// Add Session Replay plugin
const sessionReplay = sessionReplayPlugin({
sampleRate: sessionReplaySampleRate,
})
amplitude.add(sessionReplay)
}, [])
}, [sessionReplaySampleRate])
// This is a client component that renders nothing
return null

View File

@ -3,6 +3,7 @@ import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AmplitudeProvider from '../AmplitudeProvider'
import { resetAmplitudeInitializationForTests } from '../init'
const mockConfig = vi.hoisted(() => ({
AMPLITUDE_API_KEY: 'test-api-key',
@ -35,6 +36,7 @@ describe('AmplitudeProvider', () => {
vi.clearAllMocks()
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
mockConfig.IS_CLOUD_EDITION = true
resetAmplitudeInitializationForTests()
})
describe('Component', () => {
@ -46,6 +48,17 @@ describe('AmplitudeProvider', () => {
expect(amplitude.add).toHaveBeenCalledTimes(2)
})
it('does not re-initialize amplitude on remount', () => {
const { unmount } = render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
unmount()
render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
expect(amplitude.init).toHaveBeenCalledTimes(1)
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
expect(amplitude.add).toHaveBeenCalledTimes(2)
})
it('does not initialize amplitude when disabled', () => {
mockConfig.AMPLITUDE_API_KEY = ''
render(<AmplitudeProvider />)

View File

@ -0,0 +1,61 @@
import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ensureAmplitudeInitialized, resetAmplitudeInitializationForTests } from '../init'
const mockConfig = vi.hoisted(() => ({
AMPLITUDE_API_KEY: 'test-api-key',
IS_CLOUD_EDITION: true,
}))
vi.mock('@/config', () => ({
get AMPLITUDE_API_KEY() {
return mockConfig.AMPLITUDE_API_KEY
},
get IS_CLOUD_EDITION() {
return mockConfig.IS_CLOUD_EDITION
},
get isAmplitudeEnabled() {
return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY
},
}))
vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
add: vi.fn(),
}))
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
sessionReplayPlugin: vi.fn(() => ({ name: 'session-replay' })),
}))
describe('amplitude init helper', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
mockConfig.IS_CLOUD_EDITION = true
resetAmplitudeInitializationForTests()
})
describe('ensureAmplitudeInitialized', () => {
it('should initialize amplitude only once across repeated calls', () => {
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.8 })
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.2 })
expect(amplitude.init).toHaveBeenCalledTimes(1)
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
expect(sessionReplayPlugin).toHaveBeenCalledWith({ sampleRate: 0.8 })
expect(amplitude.add).toHaveBeenCalledTimes(2)
})
it('should skip initialization when amplitude is disabled', () => {
mockConfig.AMPLITUDE_API_KEY = ''
ensureAmplitudeInitialized()
expect(amplitude.init).not.toHaveBeenCalled()
expect(sessionReplayPlugin).not.toHaveBeenCalled()
expect(amplitude.add).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,82 @@
import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
export type AmplitudeInitializationOptions = {
sessionReplaySampleRate?: number
}
let isAmplitudeInitialized = false
// Map URL pathname to English page name for consistent Amplitude tracking
const getEnglishPageName = (pathname: string): string => {
// Remove leading slash and get the first segment
const segments = pathname.replace(/^\//, '').split('/')
const firstSegment = segments[0] || 'home'
const pageNameMap: Record<string, string> = {
'': 'Home',
'apps': 'Studio',
'datasets': 'Knowledge',
'explore': 'Explore',
'tools': 'Tools',
'account': 'Account',
'signin': 'Sign In',
'signup': 'Sign Up',
}
return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
}
// Enrichment plugin to override page title with English name for page view events
const createPageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
return {
name: 'page-name-enrichment',
type: 'enrichment',
setup: async () => undefined,
execute: async (event: amplitude.Types.Event) => {
// Only modify page view events
if (event.event_type === '[Amplitude] Page Viewed' && event.event_properties) {
/* v8 ignore next @preserve */
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname)
}
return event
},
}
}
export const ensureAmplitudeInitialized = ({
sessionReplaySampleRate = 0.5,
}: AmplitudeInitializationOptions = {}) => {
if (!isAmplitudeEnabled || isAmplitudeInitialized)
return
isAmplitudeInitialized = true
try {
amplitude.init(AMPLITUDE_API_KEY, {
defaultTracking: {
sessions: true,
pageViews: true,
formInteractions: true,
fileDownloads: true,
attribution: true,
},
})
amplitude.add(createPageNameEnrichmentPlugin())
amplitude.add(sessionReplayPlugin({
sampleRate: sessionReplaySampleRate,
}))
}
catch (error) {
isAmplitudeInitialized = false
throw error
}
}
// Only used by unit tests to reset module-scoped initialization state.
export const resetAmplitudeInitializationForTests = () => {
isAmplitudeInitialized = false
}

View File

@ -1,6 +1,6 @@
'use client'
/**
* @deprecated Use semantic overlay primitives from `@/app/components/base/ui/` instead.
* @deprecated Use semantic overlay primitives from `@langgenius/dify-ui/*` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*

View File

@ -54,7 +54,7 @@ describe('EditWorkspaceModal', () => {
expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument()
})
it('should render on the base/ui overlay layer', async () => {
it('should render on the dify-ui overlay layer', async () => {
renderModal()
expect(await screen.findByRole('dialog')).toHaveClass('z-1002')

View File

@ -45,7 +45,7 @@ const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
</div>
</DialogContent>
{/* TODO: reduce z-1002 to match base/ui primitives after legacy overlay migration completes */}
{/* TODO: reduce z-1002 to match @langgenius/dify-ui primitives after legacy overlay migration completes */}
<DialogPortal>
<div className="pointer-events-none fixed top-1/2 left-1/2 z-1002 flex -translate-x-1/2 translate-y-[165px] items-center gap-1 body-xs-regular text-text-quaternary">
<span>{t('onboarding.escTip.press', { ns: 'workflow' })}</span>

View File

@ -8,6 +8,7 @@ import type {
NodeTracing,
} from '@/types/workflow'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiAlertFill,
RiArrowRightSLine,
@ -16,9 +17,8 @@ import {
RiLoader2Line,
RiPauseCircleFill,
} from '@remixicon/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -68,6 +68,16 @@ const NodePanel: FC<Props> = ({
return
doSetCollapseState(state)
}, [hideProcessDetail])
const titleRef = useRef<HTMLDivElement>(null)
const [isTooltipOpen, setIsTooltipOpen] = useState(false)
const handleTooltipOpenChange = useCallback((open: boolean) => {
if (open) {
const el = titleRef.current
if (!el || el.scrollWidth <= el.clientWidth)
return
}
setIsTooltipOpen(open)
}, [])
const { t } = useTranslation()
const docLink = useDocLink()
@ -132,18 +142,23 @@ const NodePanel: FC<Props> = ({
/>
)}
<BlockIcon size={inMessage ? 'xs' : 'sm'} className={cn('mr-2 shrink-0', inMessage && 'mr-1!')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} />
<Tooltip
popupContent={
<Tooltip open={isTooltipOpen} onOpenChange={handleTooltipOpenChange}>
<TooltipTrigger
render={(
<div
ref={titleRef}
className={cn(
'min-w-0 grow truncate system-xs-semibold-uppercase text-text-secondary',
hideInfo && 'text-xs!',
)}
>
{nodeInfo.title}
</div>
)}
/>
<TooltipContent>
<div className="max-w-xs">{nodeInfo.title}</div>
}
>
<div className={cn(
'grow truncate system-xs-semibold-uppercase text-text-secondary',
hideInfo && 'text-xs!',
)}
>
{nodeInfo.title}
</div>
</TooltipContent>
</Tooltip>
{!['running', 'paused'].includes(nodeInfo.status) && !hideInfo && (
<div className="shrink-0 system-xs-regular text-text-tertiary">

View File

@ -4,6 +4,7 @@ import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
import { Provider as JotaiProvider } from 'jotai/react'
import { ThemeProvider } from 'next-themes'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { TanstackQueryInitializer } from '@/context/query-client'
import { getDatasetMap } from '@/env'
import { getLocaleOnServer } from '@/i18n-config/server'
@ -56,6 +57,7 @@ const LocaleLayout = async ({
{...datasetMap}
>
<div className="isolate h-full">
<AmplitudeProvider />
<JotaiProvider>
<ThemeProvider
attribute="data-theme"

View File

@ -1,6 +1,8 @@
# Overlay Migration Guide
This document tracks the migration away from legacy overlay APIs.
This document tracks the Dify-web migration away from legacy overlay APIs.
> **See also:** [`packages/dify-ui/README.md`] for the permanent overlay / portal / z-index contract of the replacement primitives. This document covers the one-off migration mechanics (allowlist, deprecated import paths, coexistence z-index strategy) and is expected to shrink and eventually be removed once the legacy overlays are gone.
## Scope
@ -31,7 +33,7 @@ This document tracks the migration away from legacy overlay APIs.
## Migration phases
1. Business/UI features outside `app/components/base/**`
- Migrate old calls to semantic primitives from `@/app/components/base/ui/**`.
- Migrate old calls to semantic primitives from `@langgenius/dify-ui/*`.
- Keep deprecated imports out of newly touched files.
1. Legacy base components in allowlist
- Migrate allowlisted base callers gradually.
@ -53,7 +55,7 @@ pnpm -C web lint:fix --prune-suppressions <changed-files>
## z-index strategy
All new overlay primitives in `base/ui/` share a single z-index value:
All new overlay primitives in `@langgenius/dify-ui` share a single z-index value:
**`z-1002`**, except Toast which stays one layer above at **`z-1003`**.
### Why z-[1002]?
@ -61,13 +63,13 @@ All new overlay primitives in `base/ui/` share a single z-index value:
During the migration period, legacy and new overlays coexist. Legacy overlays
portal to `document.body` with explicit z-index values:
| Layer | z-index | Components |
| --------------------------------- | -------------- | -------------------------------------------- |
| Legacy Drawer | `z-30` | `base/drawer` |
| Legacy Modal | `z-60` | `base/modal` (default) |
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
| **New UI primitives** | **`z-1002`** | `base/ui/*` (Popover, Dialog, Tooltip, etc.) |
| Toast | `z-1003` | `base/ui/toast` |
| Layer | z-index | Components |
| --------------------------------- | -------------- | -------------------------------------------------------- |
| Legacy Drawer | `z-30` | `base/drawer` |
| Legacy Modal | `z-60` | `base/modal` (default) |
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Tooltip, etc.) |
| Toast | `z-1003` | `@langgenius/dify-ui/toast` |
`z-1002` sits above all common legacy overlays, so new primitives always
render on top without needing per-call-site z-index hacks. Among themselves,
@ -81,8 +83,8 @@ back to `z-9999`.
### Rules
- **Do NOT add z-index overrides** (e.g. `className="z-1003"`) on new
`base/ui/*` components. If you find yourself needing one, the parent legacy
overlay should be migrated instead.
`@langgenius/dify-ui/*` components. If you find yourself needing one, the
parent legacy overlay should be migrated instead.
- When migrating a legacy overlay that has a high z-index, remove the z-index
entirely — the new primitive's default `z-1002` handles it.
- `portalToFollowElemContentClassName` with z-index values (e.g. `z-1000`)
@ -92,12 +94,8 @@ back to `z-9999`.
Once all legacy overlays are removed:
1. Reduce `z-1002` back to `z-50` across all `base/ui/` primitives.
1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui` primitives.
1. Reduce Toast from `z-1003` to `z-51`.
1. Remove this section from the migration guide.
## React Refresh policy for base UI primitives
- We keep primitive aliases (for example `DropdownMenu = Menu.Root`) in the same module.
- For `app/components/base/ui/**/*.tsx`, `react-refresh/only-export-components` is currently set to `off` in ESLint to avoid false positives and IDE noise during migration.
- Do not use file-level `eslint-disable` comments for this policy; keep control in the scoped ESLint override.
[`packages/dify-ui/README.md`]: ../../packages/dify-ui/README.md

View File

@ -95,7 +95,7 @@ Use `pnpm analyze-component <path>` to analyze component complexity and adopt di
- Testing time-based behavior (delays, animations)
- If you mock all time-dependent functions, fake timers are unnecessary
1. **Prefer importing over mocking project components**: When tests need other components from the project, import them directly instead of mocking them. Only mock external dependencies, APIs, or complex context providers that are difficult to set up.
1. **DO NOT mock base components**: Never mock components from `@/app/components/base/` (e.g., `Loading`, `Button`, `Tooltip`, `Modal`). Base components will have their own dedicated tests. Use real components to test actual integration behavior.
1. **DO NOT mock base components or dify-ui primitives**: Never mock components from `@/app/components/base/` (e.g., `Loading`, `Input`, `Badge`, `Tag`) or from `@langgenius/dify-ui/*` (e.g., `Button`, `Tooltip`, `Dialog`, `Select`, `Popover`). They have their own dedicated tests. Use real components to test actual integration behavior.
**Why this matters**: Mocks that don't match actual behavior can lead to:
@ -134,7 +134,7 @@ When using a single spec file:
- ✅ **Import real project components** directly (including base components and siblings)
- ✅ **Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers
- ❌ **DO NOT mock** base components (`@/app/components/base/*`)
- ❌ **DO NOT mock** base components (`@/app/components/base/*`) or dify-ui primitives (`@langgenius/dify-ui/*`)
- ❌ **DO NOT mock** sibling/child components in the same directory
> See [Example Structure] for correct import/mock patterns.
@ -539,4 +539,4 @@ Test examples in the project:
[Testing Library Best Practices]: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
[Vitest Documentation]: https://vitest.dev/guide
[Vitest Mocking Guide]: https://vitest.dev/guide/mocking.html
[index.spec.tsx]: ../app/components/base/button/index.spec.tsx
[index.spec.tsx]: ../app/components/base/radio/__tests__/index.spec.tsx

View File

@ -160,13 +160,6 @@ export default antfu(
'hyoban/no-dependency-version-prefix': 'error',
},
},
{
name: 'dify/base-ui-primitives',
files: ['app/components/base/ui/**/*.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
name: 'dify/no-direct-next-imports',
files: [GLOB_TS, GLOB_TSX],

View File

@ -26,7 +26,7 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
'**/portal-to-follow-elem',
'**/portal-to-follow-elem/index',
],
message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.',
message: 'Deprecated: use semantic overlay primitives from @langgenius/dify-ui (popover / dropdown-menu / tooltip / context-menu) instead. See issue #32767.',
},
{
group: [

View File

@ -374,13 +374,13 @@ Options:
Examples:
# Analyze a component and generate test prompt
pnpm analyze-component app/components/base/button/index.tsx
pnpm analyze-component app/components/base/radio/index.tsx
# Output as JSON
pnpm analyze-component app/components/base/button/index.tsx --json
pnpm analyze-component app/components/base/radio/index.tsx --json
# Review existing test
pnpm analyze-component app/components/base/button/index.tsx --review
pnpm analyze-component app/components/base/radio/index.tsx --review
For complete testing guidelines, see: web/docs/test.md
`)