diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md
index 9d013876dd9..3572d03e757 100644
--- a/.agents/skills/how-to-write-component/SKILL.md
+++ b/.agents/skills/how-to-write-component/SKILL.md
@@ -18,6 +18,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
+| Where should a hotkey live? | Keep a single-owner hotkey constant in its component. | Multiple production files share one command, or the feature owns a real command registry with shared metadata and behavior. |
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
## Core Defaults
@@ -77,6 +78,19 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
+## Keyboard Shortcuts
+
+- Distinguish application commands from local keyboard semantics before choosing an API. Use `@tanstack/react-hotkeys` for application commands. Keep menu navigation, dialog Escape handling owned by a primitive, editor commands, and other widget-scoped ARIA interactions in their local component or primitive.
+- Use `useHotkey` or `useHotkeys` for registered commands. For a command intentionally owned by an existing `onKeyDown`, use `matchesKeyboardEvent` instead of hand-written `metaKey` / `ctrlKey` parsing or a second global listener.
+- Define a reusable string command with `satisfies Hotkey` and an object-form command with `satisfies RawHotkey`. Reserve `RegisterableHotkey` for API boundaries that intentionally accept either form. A one-time inline literal passed directly to TanStack is already type-checked; extract it when registration, display, metadata, or another production consumer needs the same source.
+- Keep registered hotkeys distinct from held keys and display-only accelerators. Use `IndividualKey` with `useKeyHold` for held-key interactions, and use an explicitly named `displayKey` for local widget accelerators that are not registered `Hotkey` values.
+- Keep registration and keycap/menu display derived from one canonical command. Do not maintain a hotkey string beside a separate `['Mod', ...]` display array.
+- Keep a single-owner command constant in its owning component. Create a feature-local `hotkeys.ts` only when multiple production files consume the same command. Keep a dedicated definitions/registry module when a feature owns a real command system with IDs, metadata, alternate bindings, and centralized registration. Tests do not count as another production owner, and file-name uniformity alone is not a reason to extract.
+- Make scope and availability explicit. Use `enabled` for business or surface lifecycle, `ignoreInputs` for whether input-like elements may trigger the command, and `target` when the command belongs to a concrete DOM subtree. Global application commands may use the document target; inline editors and composed overlays should prefer the actual editor or Base UI Popup ref when that owner is exposed.
+- Put a scoped ref on the real behavior owner. Do not add a wrapper DOM element solely to obtain a hotkey target. If a shared overlay convenience component hides the Popup ref, either rely on its modal lifecycle/focus boundary when that is sufficient or design the primitive API separately; do not create a fake owner at the call site.
+- Set `preventDefault` and `stopPropagation` according to the existing product behavior and browser interaction. Do not silently accept TanStack defaults when migrating from another listener if that changes typing, submission, or propagation semantics.
+- Test observable command behavior, disabled/input/target scope, and the shared registration/display contract at the owning feature boundary. Prefer partial mocks that retain TanStack formatting and matching behavior when a registration boundary must be isolated.
+
## Generated API And Nullable Data
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json
index 06e93398aac..d11268cad75 100644
--- a/oxlint-suppressions.json
+++ b/oxlint-suppressions.json
@@ -757,34 +757,8 @@
"count": 1
}
},
- "web/app/components/app/create-app-modal/index.tsx": {
- "jsx_a11y/click-events-have-key-events": {
- "count": 1
- },
- "jsx_a11y/no-static-element-interactions": {
- "count": 1
- },
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/app/create-from-dsl-modal/index.tsx": {
- "erasable-syntax-only/enums": {
- "count": 1
- },
"eslint-react/set-state-in-effect": {
- "count": 2
- },
- "jsx_a11y/click-events-have-key-events": {
- "count": 2
- },
- "jsx_a11y/no-static-element-interactions": {
- "count": 2
- },
- "no-restricted-imports": {
- "count": 1
- },
- "react/only-export-components": {
"count": 1
}
},
diff --git a/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx
index 252bffd591c..af11886c8d7 100644
--- a/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx
+++ b/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx
@@ -1,30 +1,7 @@
-import type { ReactNode } from 'react'
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, render, screen } from '@testing-library/react'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
-import AppDetailTop from '../app-detail-top'
-
-vi.mock('../toggle-button', () => ({
- default: ({
- expand,
- handleToggle,
- icon,
- }: {
- expand: boolean
- handleToggle: () => void
- icon?: ReactNode
- }) => (
-
- Toggle
-
- ),
-}))
+import { AppDetailTop } from '../app-detail-top'
function TestGotoAnythingDialog() {
return (
@@ -75,10 +52,8 @@ describe('AppDetailTop', () => {
const onToggle = vi.fn()
render( )
- fireEvent.click(screen.getByTestId('toggle-button'))
+ fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
- expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'true')
- expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true')
expect(onToggle).toHaveBeenCalledTimes(1)
})
})
diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx
index c8d952df1e1..39cb314d280 100644
--- a/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx
+++ b/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx
@@ -1,30 +1,7 @@
-import type { ReactNode } from 'react'
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, render, screen } from '@testing-library/react'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
-import DatasetDetailTop from '../dataset-detail-top'
-
-vi.mock('../toggle-button', () => ({
- default: ({
- expand,
- handleToggle,
- icon,
- }: {
- expand: boolean
- handleToggle: () => void
- icon?: ReactNode
- }) => (
-
- Toggle
-
- ),
-}))
+import { DatasetDetailTop } from '../dataset-detail-top'
function TestGotoAnythingDialog() {
return (
@@ -73,10 +50,8 @@ describe('DatasetDetailTop', () => {
const onToggle = vi.fn()
render( )
- fireEvent.click(screen.getByTestId('toggle-button'))
+ fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }))
- expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'false')
- expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true')
expect(onToggle).toHaveBeenCalledTimes(1)
})
})
diff --git a/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx b/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx
deleted file mode 100644
index 4ab0ecc3388..00000000000
--- a/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import * as React from 'react'
-import ToggleButton from '../toggle-button'
-
-describe('ToggleButton', () => {
- it('should render collapse arrow when expanded', () => {
- render( )
- const button = screen.getByRole('button')
- expect(button).toBeInTheDocument()
- })
-
- it('should render expand arrow when collapsed', () => {
- render( )
- const button = screen.getByRole('button')
- expect(button).toBeInTheDocument()
- })
-
- it('should call handleToggle when clicked', async () => {
- const user = userEvent.setup()
- const handleToggle = vi.fn()
- render( )
-
- await user.click(screen.getByRole('button'))
-
- expect(handleToggle).toHaveBeenCalledTimes(1)
- })
-
- it('should apply custom className', () => {
- render( )
- const button = screen.getByRole('button')
- expect(button).toHaveClass('custom-class')
- })
-
- it('should have rounded-full style', () => {
- render( )
- const button = screen.getByRole('button')
- expect(button).toHaveClass('rounded-full')
- })
-})
diff --git a/web/app/components/app-sidebar/app-detail-sidebar.tsx b/web/app/components/app-sidebar/app-detail-sidebar.tsx
index 019dbb41f16..c17fe0f060b 100644
--- a/web/app/components/app-sidebar/app-detail-sidebar.tsx
+++ b/web/app/components/app-sidebar/app-detail-sidebar.tsx
@@ -2,7 +2,7 @@
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import AppDetailSection from './app-detail-section'
-import AppDetailTop from './app-detail-top'
+import { AppDetailTop } from './app-detail-top'
export function AppDetailSidebar() {
return (
diff --git a/web/app/components/app-sidebar/app-detail-top.tsx b/web/app/components/app-sidebar/app-detail-top.tsx
index b7578a70ba3..35d4914750a 100644
--- a/web/app/components/app-sidebar/app-detail-top.tsx
+++ b/web/app/components/app-sidebar/app-detail-top.tsx
@@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
+import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
+import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
-import ToggleButton from './toggle-button'
type AppDetailTopProps = {
expand?: boolean
onToggle?: () => void
}
-const SEARCH_SHORTCUT = ['Mod', 'K']
-
-const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
+export function AppDetailTop({ expand = true, onToggle }: AppDetailTopProps) {
const { t } = useTranslation()
if (!expand) {
return (
{onToggle && (
- }
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
@@ -82,7 +81,7 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
>
{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}
- {SEARCH_SHORTCUT.map((key) => (
+ {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
))}
@@ -90,9 +89,9 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
)}
{onToggle && (
- }
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
@@ -100,5 +99,3 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
)
}
-
-export default AppDetailTop
diff --git a/web/app/components/app-sidebar/dataset-detail-sidebar.tsx b/web/app/components/app-sidebar/dataset-detail-sidebar.tsx
index aad4d4fe0de..1d589c8e174 100644
--- a/web/app/components/app-sidebar/dataset-detail-sidebar.tsx
+++ b/web/app/components/app-sidebar/dataset-detail-sidebar.tsx
@@ -2,7 +2,7 @@
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import DatasetDetailSection from './dataset-detail-section'
-import DatasetDetailTop from './dataset-detail-top'
+import { DatasetDetailTop } from './dataset-detail-top'
export function DatasetDetailSidebar() {
return (
diff --git a/web/app/components/app-sidebar/dataset-detail-top.tsx b/web/app/components/app-sidebar/dataset-detail-top.tsx
index 27177951cae..0f4523993c2 100644
--- a/web/app/components/app-sidebar/dataset-detail-top.tsx
+++ b/web/app/components/app-sidebar/dataset-detail-top.tsx
@@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
+import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
+import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
-import ToggleButton from './toggle-button'
type DatasetDetailTopProps = {
expand?: boolean
onToggle?: () => void
}
-const SEARCH_SHORTCUT = ['Mod', 'K']
-
-const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => {
+export function DatasetDetailTop({ expand = true, onToggle }: DatasetDetailTopProps) {
const { t } = useTranslation()
if (!expand) {
return (
{onToggle && (
- }
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
@@ -82,7 +81,7 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
>
{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}
- {SEARCH_SHORTCUT.map((key) => (
+ {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
))}
@@ -90,9 +89,9 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
)}
{onToggle && (
- }
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
@@ -100,5 +99,3 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
)
}
-
-export default DatasetDetailTop
diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx
index f61cd08ae75..94f60eb26a2 100644
--- a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx
+++ b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx
@@ -140,7 +140,7 @@ type MockCreateSnippetDialogProps = {
}
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
- default: ({
+ CreateSnippetDialog: ({
isOpen,
title,
confirmText,
diff --git a/web/app/components/app-sidebar/snippet-info/dropdown.tsx b/web/app/components/app-sidebar/snippet-info/dropdown.tsx
index a2c8fa46975..27289b7c14f 100644
--- a/web/app/components/app-sidebar/snippet-info/dropdown.tsx
+++ b/web/app/components/app-sidebar/snippet-info/dropdown.tsx
@@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
-import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
+import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import {
canCreateAndModifySnippets,
canManageSnippets,
diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx
deleted file mode 100644
index 5a7c5c00db9..00000000000
--- a/web/app/components/app-sidebar/toggle-button.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { Button } from '@langgenius/dify-ui/button'
-import { cn } from '@langgenius/dify-ui/cn'
-import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
-import { formatForDisplay } from '@tanstack/react-hotkeys'
-import * as React from 'react'
-import { useTranslation } from 'react-i18next'
-
-type ToggleTooltipContentProps = {
- expand: boolean
-}
-
-const TOGGLE_SHORTCUT = ['Mod', 'B']
-
-const ToggleTooltipContent = ({ expand }: ToggleTooltipContentProps) => {
- const { t } = useTranslation()
-
- return (
-
-
- {expand
- ? t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' })
- : t(($) => $['sidebar.expandSidebar'], { ns: 'layout' })}
-
-
- {TOGGLE_SHORTCUT.map((key) => (
- {formatForDisplay(key)}
- ))}
-
-
- )
-}
-
-type ToggleButtonProps = {
- expand: boolean
- handleToggle: () => void
- className?: string
- icon?: React.ReactNode
- iconClassName?: string
-}
-
-const ToggleButton = ({
- expand,
- handleToggle,
- className,
- icon,
- iconClassName,
-}: ToggleButtonProps) => {
- return (
-
-
- }
- >
- {icon ||
- (iconClassName ? (
-
- ) : expand ? (
-
- ) : (
-
- ))}
-
-
-
-
-
- )
-}
-
-export default React.memo(ToggleButton)
diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx
index 6620ba5f59d..dda5adde69e 100644
--- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx
+++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx
@@ -285,7 +285,6 @@ describe('AppPublisher', () => {
enabled: true,
})
})
- expect(sectionProps.summary?.publishShortcut).toEqual(['Mod', 'Shift', 'P'])
expect(mockRefetch).not.toHaveBeenCalled()
})
diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx
index 358b72e2add..d14cb9fa8b2 100644
--- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx
+++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx
@@ -75,7 +75,6 @@ describe('app-publisher sections', () => {
publishDisabled={false}
published={false}
publishedAt={Date.now()}
- publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
@@ -113,7 +112,6 @@ describe('app-publisher sections', () => {
publishDisabled={false}
published={false}
publishedAt={undefined}
- publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
@@ -137,7 +135,6 @@ describe('app-publisher sections', () => {
publishDisabled={false}
published={false}
publishedAt={undefined}
- publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
@@ -161,7 +158,6 @@ describe('app-publisher sections', () => {
publishDisabled={false}
published={false}
publishedAt={undefined}
- publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded
upgradeHighlightStyle={{}}
/>,
diff --git a/web/app/components/app/app-publisher/hotkeys.ts b/web/app/components/app/app-publisher/hotkeys.ts
new file mode 100644
index 00000000000..a1b8f13b397
--- /dev/null
+++ b/web/app/components/app/app-publisher/hotkeys.ts
@@ -0,0 +1,3 @@
+import type { Hotkey } from '@tanstack/react-hotkeys'
+
+export const APP_PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx
index c2668a22360..e3735481f9d 100644
--- a/web/app/components/app/app-publisher/index.tsx
+++ b/web/app/components/app/app-publisher/index.tsx
@@ -1,4 +1,3 @@
-import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
import type { FormEvent } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type {
@@ -45,6 +44,7 @@ import { fetchPublishedWorkflow } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AccessControl from '../app-access-control'
+import { APP_PUBLISH_HOTKEY } from './hotkeys'
import {
PublisherAccessSection,
PublisherActionsSection,
@@ -81,9 +81,6 @@ export type AppPublisherProps = {
hasHumanInputNode?: boolean
}
-const PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey
-const PUBLISH_SHORTCUT = PUBLISH_HOTKEY.split('+')
-
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
type AppPublisherPublishHandler =
@@ -307,7 +304,7 @@ export function AppPublisher({
}
}
- useHotkey(PUBLISH_HOTKEY, (e) => {
+ useHotkey(APP_PUBLISH_HOTKEY, (e) => {
e.preventDefault()
if (publishDisabled || published) return
handlePublish()
@@ -410,7 +407,6 @@ export function AppPublisher({
publishDisabled={publishDisabled}
published={published}
publishedAt={publishedAt}
- publishShortcut={PUBLISH_SHORTCUT}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
/>
diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx
index d3e0417d8a2..eb4bbc6f0e3 100644
--- a/web/app/components/app/app-publisher/sections.tsx
+++ b/web/app/components/app/app-publisher/sections.tsx
@@ -12,6 +12,7 @@ import Loading from '@/app/components/base/loading'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { AppModeEnum } from '@/types/app'
+import { APP_PUBLISH_HOTKEY } from './hotkeys'
import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action'
import { ACCESS_MODE_MAP } from './utils'
@@ -30,7 +31,6 @@ type SummarySectionProps = Pick<
handleRestore: () => Promise
isChatApp: boolean
published: boolean
- publishShortcut: string[]
upgradeHighlightStyle: CSSProperties
}
@@ -109,7 +109,6 @@ export const PublisherSummarySection = ({
publishDisabled = false,
published,
publishedAt,
- publishShortcut,
startNodeLimitExceeded = false,
upgradeHighlightStyle,
}: SummarySectionProps) => {
@@ -163,7 +162,7 @@ export const PublisherSummarySection = ({
{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}
- {publishShortcut.map((key) => (
+ {APP_PUBLISH_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx
index 5116559f2d5..14f0869b54f 100644
--- a/web/app/components/app/create-app-modal/index.tsx
+++ b/web/app/components/app/create-app-modal/index.tsx
@@ -1,8 +1,10 @@
'use client'
+import type { Hotkey } from '@tanstack/react-hotkeys'
import type { AppIconSelection } from '../../base/app-icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import { Input } from '@langgenius/dify-ui/input'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
@@ -22,7 +24,6 @@ import {
ListSparkle,
Logic,
} from '@/app/components/base/icons/src/vender/solid/communication'
-import Input from '@/app/components/base/input'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { userProfileIdAtom } from '@/context/account-state'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
@@ -47,6 +48,8 @@ type CreateAppProps = {
defaultAppMode?: AppModeEnum
}
+const CREATE_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
+
const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => {
return (
appMode === AppModeEnum.CHAT ||
@@ -152,7 +155,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useHotkey(
- 'Mod+Enter',
+ CREATE_APP_HOTKEY,
() => {
if (isAppsFull || !canCreateApp) return
handleCreateApp()
@@ -348,7 +351,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
>
{t(($) => $['newApp.Create'], { ns: 'app' })}
- {['Mod', 'Enter'].map((key) => (
+ {CREATE_APP_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
@@ -434,9 +437,10 @@ type AppTypeCardProps = {
}
function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardProps) {
return (
-
{description}
-
+
)
}
diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx
index c50025b263f..ab8f378d558 100644
--- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx
+++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx
@@ -4,7 +4,8 @@ import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-syste
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
import { DSLImportMode, DSLImportStatus } from '@/models/app'
import { AppModeEnum } from '@/types/app'
-import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
+import CreateFromDSLModal from '../index'
+import { CreateFromDSLModalTab } from '../types'
const mockPush = vi.fn()
const mockImportDSL = vi.fn()
diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx
index 929646abac4..f5cd1db9b56 100644
--- a/web/app/components/app/create-from-dsl-modal/index.tsx
+++ b/web/app/components/app/create-from-dsl-modal/index.tsx
@@ -1,9 +1,11 @@
'use client'
+import type { Hotkey } from '@tanstack/react-hotkeys'
import type { MouseEventHandler } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
+import { Input } from '@langgenius/dify-ui/input'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { toast } from '@langgenius/dify-ui/toast'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
@@ -13,7 +15,6 @@ import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
-import Input from '@/app/components/base/input'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { userProfileIdAtom } from '@/context/account-state'
@@ -26,21 +27,19 @@ import { importDSL, importDSLConfirm } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking'
+import { CreateFromDSLModalTab } from './types'
import Uploader from './uploader'
type CreateFromDSLModalProps = {
show: boolean
onSuccess?: () => void
onClose: () => void
- activeTab?: string
+ activeTab?: CreateFromDSLModalTab
dslUrl?: string
droppedFile?: File
}
-export enum CreateFromDSLModalTab {
- FROM_FILE = 'from-file',
- FROM_URL = 'from-url',
-}
+const CREATE_FROM_DSL_HOTKEY = 'Mod+Enter' satisfies Hotkey
const CreateFromDSLModal = ({
show,
@@ -91,8 +90,8 @@ const CreateFromDSLModal = ({
const isCreatingRef = useRef(false)
useEffect(() => {
- if (droppedFile) handleFile(droppedFile)
- }, [droppedFile, handleFile])
+ if (droppedFile) readFile(droppedFile)
+ }, [droppedFile, readFile])
const onCreate = async (_e?: React.MouseEvent) => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return
@@ -179,7 +178,7 @@ const CreateFromDSLModal = ({
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useHotkey(
- 'Mod+Enter',
+ CREATE_FROM_DSL_HOTKEY,
() => {
handleCreateApp(undefined)
},
@@ -251,16 +250,23 @@ const CreateFromDSLModal = ({
{t(($) => $.importApp, { ns: 'app' })}
-
onClose()}>
-
-
+
$['operation.cancel'], { ns: 'common' })}
+ className="size-8 p-0"
+ onClick={onClose}
+ >
+
+
{tabs.map((tab) => (
-
setCurrentTab(tab.key)}
@@ -269,7 +275,7 @@ const CreateFromDSLModal = ({
{currentTab === tab.key && (
)}
-
+
))}
@@ -304,7 +310,7 @@ const CreateFromDSLModal = ({
>
{t(($) => $['newApp.Create'], { ns: 'app' })}
- {['Mod', 'Enter'].map((key) => (
+ {CREATE_FROM_DSL_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
diff --git a/web/app/components/app/create-from-dsl-modal/types.ts b/web/app/components/app/create-from-dsl-modal/types.ts
new file mode 100644
index 00000000000..0cf1a8df7c0
--- /dev/null
+++ b/web/app/components/app/create-from-dsl-modal/types.ts
@@ -0,0 +1,7 @@
+export const CreateFromDSLModalTab = {
+ FROM_FILE: 'from-file',
+ FROM_URL: 'from-url',
+} as const
+
+export type CreateFromDSLModalTab =
+ (typeof CreateFromDSLModalTab)[keyof typeof CreateFromDSLModalTab]
diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx
index a417a5e122b..b4853c5ccbb 100644
--- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx
@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
+import { CreateFromDSLModalTab } from '../../hooks/use-dsl-import'
import Tab from '../index'
// Tab Component Tests
diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx
index 129df9e34a8..5af2e53cb4a 100644
--- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx
+++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx
@@ -1,6 +1,6 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
-import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
+import { CreateFromDSLModalTab } from '../hooks/use-dsl-import'
import Item from './item'
type TabProps = {
diff --git a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx
index 8252496e1e1..8ee5b16c345 100644
--- a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx
+++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx
@@ -49,7 +49,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({
}))
vi.mock('../completed/common/action-buttons', () => ({
- default: ({
+ ActionButtons: ({
handleCancel,
handleSave,
loading,
diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx
index a207f6eb4c7..2de2284ee80 100644
--- a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx
+++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx
@@ -31,7 +31,7 @@ vi.mock('@/context/event-emitter', () => ({
}))
vi.mock('../common/action-buttons', () => ({
- default: ({
+ ActionButtons: ({
handleCancel,
handleSave,
loading,
diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx
index 313b693b001..ef3d120f07c 100644
--- a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx
+++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx
@@ -45,7 +45,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({
}))
vi.mock('../common/action-buttons', () => ({
- default: ({
+ ActionButtons: ({
handleCancel,
handleSave,
loading,
diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx
index 8b8682d83a6..e5bb4c62ef7 100644
--- a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx
+++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx
@@ -53,7 +53,7 @@ vi.mock('@/context/event-emitter', () => ({
}))
vi.mock('../common/action-buttons', () => ({
- default: ({
+ ActionButtons: ({
handleCancel,
handleSave,
handleRegeneration,
diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx
index 0bb4b2daa02..cff2be97ed3 100644
--- a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx
+++ b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx
@@ -9,7 +9,7 @@ import Divider from '@/app/components/base/divider'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { formatNumber } from '@/utils/format'
import { formatTime } from '@/utils/time'
-import ActionButtons from './common/action-buttons'
+import { ActionButtons } from './common/action-buttons'
import ChunkContent from './common/chunk-content'
import Dot from './common/dot'
import { SegmentIndexTag } from './common/segment-index-tag'
diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx
index 1861c55ed7b..01ee0f70968 100644
--- a/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx
+++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { DocumentContext } from '../../../context'
-import ActionButtons from '../action-buttons'
+import { ActionButtons } from '../action-buttons'
const mockUseHotkey = vi.fn()
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx
index a8508c7572a..77444f35283 100644
--- a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx
+++ b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx
@@ -1,14 +1,15 @@
-import type { FC } from 'react'
+import type { Hotkey } from '@tanstack/react-hotkeys'
import { Button } from '@langgenius/dify-ui/button'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
-import * as React from 'react'
-import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ChunkingMode } from '@/models/datasets'
import { useDocumentContext } from '../../context'
-type IActionButtonsProps = {
+const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
+const SAVE_HOTKEY = 'Mod+S' satisfies Hotkey
+
+type ActionButtonsProps = {
handleCancel: () => void
handleSave: () => void
loading: boolean
@@ -18,7 +19,7 @@ type IActionButtonsProps = {
showRegenerationButton?: boolean
}
-const ActionButtons: FC = ({
+export function ActionButtons({
handleCancel,
handleSave,
loading,
@@ -26,25 +27,24 @@ const ActionButtons: FC = ({
handleRegeneration,
isChildChunk = false,
showRegenerationButton = true,
-}) => {
+}: ActionButtonsProps) {
const { t } = useTranslation()
const docForm = useDocumentContext((s) => s.docForm)
const parentMode = useDocumentContext((s) => s.parentMode)
- useHotkey('Escape', (e) => {
+ useHotkey(CANCEL_HOTKEY, (e) => {
e.preventDefault()
handleCancel()
})
- useHotkey('Mod+S', (e) => {
+ useHotkey(SAVE_HOTKEY, (e) => {
e.preventDefault()
if (loading) return
handleSave()
})
- const isParentChildParagraphMode = useMemo(() => {
- return docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
- }, [docForm, parentMode])
+ const isParentChildParagraphMode =
+ docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
return (
@@ -53,7 +53,7 @@ const ActionButtons: FC = ({
{t(($) => $['operation.cancel'], { ns: 'common' })}
- {formatForDisplay('Escape')}
+ {formatForDisplay(CANCEL_HOTKEY)}
{isParentChildParagraphMode &&
@@ -72,7 +72,7 @@ const ActionButtons: FC = ({
{t(($) => $['operation.save'], { ns: 'common' })}
- {['Mod', 'S'].map((key) => (
+ {SAVE_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
@@ -83,7 +83,3 @@ const ActionButtons: FC = ({
)
}
-
-ActionButtons.displayName = 'ActionButtons'
-
-export default React.memo(ActionButtons)
diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx
index bbda2bc0539..c7ba7e3e318 100644
--- a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx
+++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx
@@ -11,7 +11,7 @@ import { useParams } from '@/next/navigation'
import { useAddChildSegment } from '@/service/knowledge/use-segment'
import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../context'
-import ActionButtons from './common/action-buttons'
+import { ActionButtons } from './common/action-buttons'
import AddAnother from './common/add-another'
import ChunkContent from './common/chunk-content'
import Dot from './common/dot'
diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx
index de39b2a503c..7a49fe808a6 100644
--- a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx
+++ b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx
@@ -13,7 +13,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
import { ChunkingMode } from '@/models/datasets'
import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../context'
-import ActionButtons from './common/action-buttons'
+import { ActionButtons } from './common/action-buttons'
import ChunkContent from './common/chunk-content'
import Dot from './common/dot'
import Keywords from './common/keywords'
diff --git a/web/app/components/datasets/documents/detail/new-segment.tsx b/web/app/components/datasets/documents/detail/new-segment.tsx
index e03b61ad441..392b888a48a 100644
--- a/web/app/components/datasets/documents/detail/new-segment.tsx
+++ b/web/app/components/datasets/documents/detail/new-segment.tsx
@@ -15,7 +15,7 @@ import { useAddSegment } from '@/service/knowledge/use-segment'
import { formatNumber } from '@/utils/format'
import { IndexingType } from '../../create/step-two'
import { useSegmentListContext } from './completed'
-import ActionButtons from './completed/common/action-buttons'
+import { ActionButtons } from './completed/common/action-buttons'
import AddAnother from './completed/common/add-another'
import ChunkContent from './completed/common/chunk-content'
import Dot from './completed/common/dot'
diff --git a/web/app/components/detail-sidebar/__tests__/index.spec.tsx b/web/app/components/detail-sidebar/__tests__/index.spec.tsx
index c59069cc525..90ea5423927 100644
--- a/web/app/components/detail-sidebar/__tests__/index.spec.tsx
+++ b/web/app/components/detail-sidebar/__tests__/index.spec.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from '@testing-library/react'
+import { act, fireEvent, render, screen } from '@testing-library/react'
import { DetailSidebarFrame } from '..'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
@@ -6,8 +6,8 @@ const { hotkeyRegistrations } = vi.hoisted(() => ({
hotkeyRegistrations: new Map<
string,
{
- handler: (event: { preventDefault: () => void }) => void
- options?: { ignoreInputs?: boolean }
+ handler: () => void
+ options?: { ignoreInputs?: boolean; preventDefault?: boolean }
}
>(),
}))
@@ -25,8 +25,8 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
...actual,
useHotkey: (
hotkey: string,
- handler: (event: { preventDefault: () => void }) => void,
- options?: { ignoreInputs?: boolean },
+ handler: () => void,
+ options?: { ignoreInputs?: boolean; preventDefault?: boolean },
) => {
hotkeyRegistrations.set(hotkey, { handler, options })
},
@@ -117,10 +117,19 @@ describe('DetailSidebarFrame', () => {
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual(
- expect.objectContaining({ ignoreInputs: false }),
+ expect.objectContaining({ ignoreInputs: false, preventDefault: true }),
)
})
+ it('toggles detail content from the registered shortcut', () => {
+ renderDetailSidebarFrame()
+
+ act(() => hotkeyRegistrations.get('Mod+B')?.handler())
+
+ expect(screen.getByRole('complementary')).toHaveClass('w-16')
+ expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'false')
+ })
+
it('collapses detail content from the top toggle and hides environment metadata', () => {
mockAppContextState.current = {
langGeniusVersionInfo: {
diff --git a/web/app/components/detail-sidebar/__tests__/toggle-button.spec.tsx b/web/app/components/detail-sidebar/__tests__/toggle-button.spec.tsx
new file mode 100644
index 00000000000..6f3c66ff446
--- /dev/null
+++ b/web/app/components/detail-sidebar/__tests__/toggle-button.spec.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { DetailSidebarToggleButton } from '../toggle-button'
+
+describe('DetailSidebarToggleButton', () => {
+ it('labels the expanded sidebar action', () => {
+ render( )
+
+ expect(
+ screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }),
+ ).toBeInTheDocument()
+ })
+
+ it('labels the collapsed sidebar action', () => {
+ render( )
+
+ expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
+ })
+
+ it('toggles the sidebar when activated', async () => {
+ const user = userEvent.setup()
+ const onToggle = vi.fn()
+ render( )
+
+ await user.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
+
+ expect(onToggle).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/detail-sidebar/hotkeys.ts b/web/app/components/detail-sidebar/hotkeys.ts
new file mode 100644
index 00000000000..45f19d1c289
--- /dev/null
+++ b/web/app/components/detail-sidebar/hotkeys.ts
@@ -0,0 +1,3 @@
+import type { Hotkey } from '@tanstack/react-hotkeys'
+
+export const DETAIL_SIDEBAR_TOGGLE_HOTKEY = 'Mod+B' satisfies Hotkey
diff --git a/web/app/components/detail-sidebar/index.tsx b/web/app/components/detail-sidebar/index.tsx
index fba1442ac4e..89a3567a5d1 100644
--- a/web/app/components/detail-sidebar/index.tsx
+++ b/web/app/components/detail-sidebar/index.tsx
@@ -4,11 +4,12 @@ import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useAtomValue } from 'jotai'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import EnvNav from '@/app/components/header/env-nav'
import AccountSection from '@/app/components/main-nav/components/account-section'
import HelpMenu from '@/app/components/main-nav/components/help-menu'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
+import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys'
import { useDetailSidebarMode } from './storage'
type DetailSidebarRenderProps = {
@@ -56,7 +57,7 @@ export function DetailSidebarFrame({
const currentEnv = langGeniusVersionInfo?.current_env
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
- const handleToggleDetailNavigation = useCallback(() => {
+ function handleToggleDetailNavigation() {
if (isDetailNavigationHoverPreviewOpen) {
if (detailNavigationTransitionTimerRef.current)
clearTimeout(detailNavigationTransitionTimerRef.current)
@@ -73,25 +74,25 @@ export function DetailSidebarFrame({
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
setDetailNavigationHoverPreviewOpen(false)
setStoredDetailSidebarExpand(nextMode)
- }, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand])
+ }
- const openDetailNavigationHoverPreview = useCallback(() => {
+ function openDetailNavigationHoverPreview() {
if (detailNavigationExpanded) return
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
setDetailNavigationHoverPreviewOpen(true)
- }, [detailNavigationExpanded])
+ }
- const closeDetailNavigationHoverPreview = useCallback(() => {
+ function closeDetailNavigationHoverPreview() {
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
setDetailNavigationHoverPreviewOpen(false)
}, 120)
- }, [])
+ }
useEffect(() => {
return () => {
@@ -102,16 +103,10 @@ export function DetailSidebarFrame({
}
}, [])
- useHotkey(
- 'Mod+B',
- (e) => {
- e.preventDefault()
- handleToggleDetailNavigation()
- },
- {
- ignoreInputs: false,
- },
- )
+ useHotkey(DETAIL_SIDEBAR_TOGGLE_HOTKEY, handleToggleDetailNavigation, {
+ ignoreInputs: false,
+ preventDefault: true,
+ })
return (
void
+ className?: string
+ icon?: ReactNode
+}
+
+export function DetailSidebarToggleButton({
+ expand,
+ onToggle,
+ className,
+ icon,
+}: DetailSidebarToggleButtonProps) {
+ const { t } = useTranslation()
+ const label = expand
+ ? t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' })
+ : t(($) => $['sidebar.expandSidebar'], { ns: 'layout' })
+
+ return (
+
+
+ }
+ >
+ {icon ??
+ (expand ? (
+
+ ) : (
+
+ ))}
+
+
+
+ {label}
+
+ {detailSidebarToggleShortcutKeys.map((key) => (
+ {formatForDisplay(key)}
+ ))}
+
+
+
+
+ )
+}
diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx
index 7746c78c129..be922c4d3ff 100644
--- a/web/app/components/explore/create-app-modal/index.tsx
+++ b/web/app/components/explore/create-app-modal/index.tsx
@@ -1,4 +1,5 @@
'use client'
+import type { Hotkey } from '@tanstack/react-hotkeys'
import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
@@ -45,6 +46,8 @@ export type CreateAppModalProps = {
type CreateAppPayload = Parameters[0]
+const SUBMIT_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
+
const CreateAppModal = ({
show = false,
isEditModal = false,
@@ -118,7 +121,7 @@ const CreateAppModal = ({
const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 })
useHotkey(
- 'Mod+Enter',
+ SUBMIT_APP_HOTKEY,
() => {
handleSubmit()
},
@@ -237,7 +240,7 @@ const CreateAppModal = ({
: t(($) => $['operation.save'], { ns: 'common' })}
- {['Mod', 'Enter'].map((key) => (
+ {SUBMIT_APP_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
diff --git a/web/app/components/goto-anything/hotkeys.ts b/web/app/components/goto-anything/hotkeys.ts
new file mode 100644
index 00000000000..98ef6f2dac3
--- /dev/null
+++ b/web/app/components/goto-anything/hotkeys.ts
@@ -0,0 +1,3 @@
+import type { Hotkey } from '@tanstack/react-hotkeys'
+
+export const GOTO_ANYTHING_HOTKEY = 'Mod+K' satisfies Hotkey
diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx
index 147cc98e57e..33096acd339 100644
--- a/web/app/components/goto-anything/index.tsx
+++ b/web/app/components/goto-anything/index.tsx
@@ -51,12 +51,11 @@ import { addRecentItem, getRecentItems } from './actions/recent-store'
import { EmptyState } from './components/empty-state'
import { Footer } from './components/footer'
import { gotoAnythingDialogHandle } from './dialog-handle'
+import { GOTO_ANYTHING_HOTKEY } from './hotkeys'
const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/
const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/
const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/
-const searchHotkey = 'Mod+K'
-const searchShortcut = searchHotkey.split('+')
type CommandOption = {
kind: 'command-option'
@@ -288,7 +287,7 @@ function GotoAnythingDialog() {
}
useHotkey(
- searchHotkey,
+ GOTO_ANYTHING_HOTKEY,
(event) => {
if (event.defaultPrevented) return
if (!gotoAnythingDialogHandle.isOpen && isEditableShortcutTarget(event.target)) return
@@ -436,7 +435,7 @@ function GotoAnythingDialog() {
)}
- {searchShortcut.map((key) => (
+ {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
))}
diff --git a/web/app/components/main-nav/components/search-button.tsx b/web/app/components/main-nav/components/search-button.tsx
index 3fa360fd4dd..1539d38369f 100644
--- a/web/app/components/main-nav/components/search-button.tsx
+++ b/web/app/components/main-nav/components/search-button.tsx
@@ -5,8 +5,7 @@ import { Kbd } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
-
-const searchShortcut = ['Mod', 'K']
+import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
export function MainNavSearchButton() {
const { t } = useTranslation()
@@ -24,7 +23,7 @@ export function MainNavSearchButton() {
>
- {searchShortcut.map((key) => (
+ {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
))}
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx
index 33aead3e493..04f05096f4e 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx
@@ -5,8 +5,8 @@ import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import RagPipelineHeader from '../index'
import InputFieldButton from '../input-field-button'
import Publisher from '../publisher'
-import Popup from '../publisher/popup'
-import RunMode from '../run-mode'
+import { Popup } from '../publisher/popup'
+import { RunMode } from '../run-mode'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal()
@@ -1080,12 +1080,6 @@ describe('RunMode', () => {
expect(wrapper)!.toHaveClass('items-center')
})
})
-
- describe('Memoization', () => {
- it('should be wrapped in React.memo', () => {
- expect((RunMode as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
- })
- })
})
describe('Integration', () => {
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx
index d986b5ecfd9..205fa856edd 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx
@@ -1,11 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import RunMode from '../run-mode'
+import { RunMode } from '../run-mode'
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleStopRun = vi.fn()
const mockSetIsPreparingDataSource = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
+const hotkeyRegistrations = vi.hoisted(
+ () =>
+ new Map<
+ string,
+ {
+ callback: () => void
+ options?: { enabled?: boolean; ignoreInputs?: boolean; preventDefault?: boolean }
+ }
+ >(),
+)
+
+vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useHotkey: (
+ hotkey: string,
+ callback: () => void,
+ options?: { enabled?: boolean; ignoreInputs?: boolean; preventDefault?: boolean },
+ ) => {
+ hotkeyRegistrations.set(hotkey, { callback, options })
+ },
+ }
+})
let mockWorkflowRunningData: { task_id: string; result: { status: string } } | undefined
let mockIsPreparingDataSource = false
@@ -75,6 +99,7 @@ describe('RunMode', () => {
vi.clearAllMocks()
mockWorkflowRunningData = undefined
mockIsPreparingDataSource = false
+ hotkeyRegistrations.clear()
})
describe('Idle state', () => {
@@ -109,6 +134,20 @@ describe('RunMode', () => {
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalled()
})
+
+ it('should run through the enabled application shortcut', () => {
+ render( )
+
+ const registration = hotkeyRegistrations.get('Alt+R')
+ registration?.callback()
+
+ expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledOnce()
+ expect(registration?.options).toEqual({
+ enabled: true,
+ ignoreInputs: true,
+ preventDefault: true,
+ })
+ })
})
describe('Running state', () => {
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/hotkeys.ts b/web/app/components/rag-pipeline/components/rag-pipeline-header/hotkeys.ts
new file mode 100644
index 00000000000..5be8e60bba2
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/hotkeys.ts
@@ -0,0 +1,4 @@
+import type { Hotkey } from '@tanstack/react-hotkeys'
+
+export const RAG_PIPELINE_RUN_HOTKEY = 'Alt+R' satisfies Hotkey
+export const RAG_PIPELINE_PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx
index b2ba2669258..49b89c63c59 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx
@@ -4,7 +4,7 @@ import Header from '@/app/components/workflow/header'
import { useStore } from '@/app/components/workflow/store'
import InputFieldButton from './input-field-button'
import Publisher from './publisher'
-import RunMode from './run-mode'
+import { RunMode } from './run-mode'
const RagPipelineHeader = () => {
const pipelineId = useStore((s) => s.pipelineId)
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx
index f5f88980d61..f8b9b0b0720 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx
@@ -4,7 +4,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Publisher from '../index'
-import Popup from '../popup'
+import { Popup } from '../popup'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal()
@@ -971,13 +971,6 @@ describe('publisher', () => {
})
})
- describe('Component Memoization', () => {
- it('should be memoized with React.memo', () => {
- expect(Popup).toBeDefined()
- expect((Popup as unknown as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol')
- })
- })
-
describe('Prop Variations', () => {
it('should display correct width when permission is allowed', () => {
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx
index 7c111a2260d..b15b940480f 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx
@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import Popup from '../popup'
+import { Popup } from '../popup'
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx
index c59846c4efb..d6222a20f1e 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx
@@ -17,7 +17,7 @@ import {
usePublishAsCustomizedPipeline,
} from '@/service/use-pipeline'
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
-import Popup from './popup'
+import { Popup } from './popup'
const Publisher = () => {
const { t } = useTranslation()
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
index ebed6c6c7e7..2bd364da9b3 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
@@ -16,7 +16,7 @@ import { RiArrowRightUpLine, RiPlayCircleLine, RiTerminalBoxLine } from '@remixi
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useBoolean } from 'ahooks'
import { useAtomValue } from 'jotai'
-import { memo, useCallback, useState } from 'react'
+import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Divider from '@/app/components/base/divider'
@@ -42,8 +42,8 @@ import { useInvalid } from '@/service/use-base'
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
import { usePublishWorkflow } from '@/service/use-workflow'
import { getDatasetACLCapabilities } from '@/utils/permission'
+import { RAG_PIPELINE_PUBLISH_HOTKEY } from '../hotkeys'
-const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
type PopupProps = {
onRequestClose?: () => void
confirmVisible?: boolean
@@ -53,14 +53,14 @@ type PopupProps = {
onShowPublishAsKnowledgePipelineModal?: () => void
}
-const Popup = ({
+export function Popup({
onRequestClose,
confirmVisible: controlledConfirmVisible,
onShowConfirm,
onHideConfirm,
isPublishingAsCustomizedPipeline = false,
onShowPublishAsKnowledgePipelineModal,
-}: PopupProps) => {
+}: PopupProps) {
const { t } = useTranslation()
const { datasetId } = useParams()
const { push } = useRouter()
@@ -182,10 +182,10 @@ const Popup = ({
handleHideConfirm,
],
)
- useHotkey('Mod+Shift+P', (e) => {
- e.preventDefault()
- if (published) return
- handlePublish()
+ useHotkey(RAG_PIPELINE_PUBLISH_HOTKEY, () => void handlePublish(), {
+ enabled: !published && !publishing,
+ ignoreInputs: true,
+ preventDefault: true,
})
const goToAddDocuments = useCallback(() => {
if (isAddDocumentsDisabled) return
@@ -243,7 +243,7 @@ const Popup = ({
{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}
- {PUBLISH_SHORTCUT.map((key) => (
+ {RAG_PIPELINE_PUBLISH_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
@@ -334,4 +334,3 @@ const Popup = ({
)
}
-export default memo(Popup)
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx
index cb7e5abe1c8..26ec0765d9d 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx
@@ -1,8 +1,7 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { RiCloseLine, RiDatabase2Line, RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
-import { formatForDisplay } from '@tanstack/react-hotkeys'
-import * as React from 'react'
+import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
@@ -12,12 +11,13 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
+import { RAG_PIPELINE_RUN_HOTKEY } from './hotkeys'
type RunModeProps = {
text?: string
}
-const RunMode = ({ text }: RunModeProps) => {
+export function RunMode({ text }: RunModeProps) {
const { t } = useTranslation()
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
const { handleStopRun } = useWorkflowRun()
@@ -44,6 +44,17 @@ const RunMode = ({ text }: RunModeProps) => {
if (v.type === EVENT_WORKFLOW_STOP) handleStop()
})
+ function handleRun() {
+ if (isDisabled) return
+ handleWorkflowStartRunInWorkflow()
+ }
+
+ useHotkey(RAG_PIPELINE_RUN_HOTKEY, handleRun, {
+ enabled: !isDisabled,
+ ignoreInputs: true,
+ preventDefault: true,
+ })
+
if (!canRun) return null
return (
@@ -55,9 +66,7 @@ const RunMode = ({ text }: RunModeProps) => {
isDisabled && 'cursor-not-allowed bg-state-accent-hover',
isDisabled ? 'rounded-l-md' : 'rounded-md',
)}
- onClick={() => {
- if (canRun) handleWorkflowStartRunInWorkflow()
- }}
+ onClick={handleRun}
disabled={isDisabled}
>
{!isDisabled && (
@@ -82,7 +91,7 @@ const RunMode = ({ text }: RunModeProps) => {
)}
{!isDisabled && (
- {['Alt', 'R'].map((key) => (
+ {RAG_PIPELINE_RUN_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
))}
@@ -113,5 +122,3 @@ const RunMode = ({ text }: RunModeProps) => {
)
}
-
-export default React.memo(RunMode)
diff --git a/web/app/components/snippet-list/__tests__/index.spec.tsx b/web/app/components/snippet-list/__tests__/index.spec.tsx
index 60b10847fce..7dc703def99 100644
--- a/web/app/components/snippet-list/__tests__/index.spec.tsx
+++ b/web/app/components/snippet-list/__tests__/index.spec.tsx
@@ -184,7 +184,7 @@ vi.mock('@/features/tag-management/components/tag-filter', () => ({
}))
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
- default: () => null,
+ CreateSnippetDialog: () => null,
}))
vi.mock('@/features/tag-management/components/tag-selector', () => ({
diff --git a/web/app/components/snippet-list/components/snippet-card.tsx b/web/app/components/snippet-list/components/snippet-card.tsx
index eea79ce775c..12914ba21e5 100644
--- a/web/app/components/snippet-list/components/snippet-card.tsx
+++ b/web/app/components/snippet-list/components/snippet-card.tsx
@@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
+import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import {
canCreateAndModifySnippets,
canManageSnippets,
diff --git a/web/app/components/snippet-list/components/snippet-create-button.tsx b/web/app/components/snippet-list/components/snippet-create-button.tsx
index 7819cda3d50..7595cd6ce96 100644
--- a/web/app/components/snippet-list/components/snippet-create-button.tsx
+++ b/web/app/components/snippet-list/components/snippet-create-button.tsx
@@ -4,7 +4,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
+import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import { useCreateSnippet } from '@/app/components/snippets/hooks/use-create-snippet'
import ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-dialog'
diff --git a/web/app/components/snippets/__tests__/create-snippet-dialog.spec.tsx b/web/app/components/snippets/__tests__/create-snippet-dialog.spec.tsx
index 51fcf1e8976..f71d84cf05b 100644
--- a/web/app/components/snippets/__tests__/create-snippet-dialog.spec.tsx
+++ b/web/app/components/snippets/__tests__/create-snippet-dialog.spec.tsx
@@ -3,15 +3,43 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PipelineInputVarType } from '@/models/pipeline'
import { expectLoadingButton } from '@/test/button'
-import CreateSnippetDialog from '../create-snippet-dialog'
+import { CreateSnippetDialog } from '../create-snippet-dialog'
let capturedKeyPressHandler: (() => void) | undefined
+let capturedHotkey: string | undefined
+let capturedHotkeyOptions:
+ | {
+ enabled?: boolean
+ ignoreInputs?: boolean
+ preventDefault?: boolean
+ stopPropagation?: boolean
+ target?: React.RefObject
+ }
+ | undefined
-vi.mock('ahooks', () => ({
- useKeyPress: (_keys: string[], handler: () => void) => {
- capturedKeyPressHandler = handler
- },
-}))
+vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useHotkey: (
+ hotkey: string,
+ handler: () => void,
+ options?: {
+ enabled?: boolean
+ ignoreInputs?: boolean
+ preventDefault?: boolean
+ stopPropagation?: boolean
+ target?: React.RefObject
+ },
+ ) => {
+ capturedHotkey = hotkey
+ capturedKeyPressHandler = () => {
+ if (options?.enabled !== false) handler()
+ }
+ capturedHotkeyOptions = options
+ },
+ }
+})
const selectedGraph: SnippetCanvasData = {
nodes: [],
@@ -32,6 +60,8 @@ describe('CreateSnippetDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedKeyPressHandler = undefined
+ capturedHotkey = undefined
+ capturedHotkeyOptions = undefined
})
it('should submit trimmed snippet values with the selected graph and input fields', async () => {
@@ -178,6 +208,14 @@ describe('CreateSnippetDialog', () => {
name: 'Keyboard snippet',
}),
)
+ expect(capturedHotkeyOptions).toMatchObject({
+ enabled: true,
+ ignoreInputs: false,
+ preventDefault: false,
+ stopPropagation: false,
+ })
+ expect(capturedHotkeyOptions?.target?.current).toBe(screen.getByRole('dialog'))
+ expect(capturedHotkey).toBe('Mod+Enter')
})
it('should disable form controls while submitting', () => {
diff --git a/web/app/components/snippets/components/snippet-detail-sidebar.tsx b/web/app/components/snippets/components/snippet-detail-sidebar.tsx
index 7b4157f534b..7ff2feb08ee 100644
--- a/web/app/components/snippets/components/snippet-detail-sidebar.tsx
+++ b/web/app/components/snippets/components/snippet-detail-sidebar.tsx
@@ -2,7 +2,7 @@
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import { SnippetDetailSection } from './snippet-detail-section'
-import SnippetDetailTop from './snippet-detail-top'
+import { SnippetDetailTop } from './snippet-detail-top'
export function SnippetDetailSidebar() {
return (
diff --git a/web/app/components/snippets/components/snippet-detail-top.tsx b/web/app/components/snippets/components/snippet-detail-top.tsx
index 0d643f30304..5e43cfbf51d 100644
--- a/web/app/components/snippets/components/snippet-detail-top.tsx
+++ b/web/app/components/snippets/components/snippet-detail-top.tsx
@@ -6,19 +6,18 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
+import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
+import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
-import ToggleButton from '../../app-sidebar/toggle-button'
type SnippetDetailTopProps = {
expand?: boolean
onToggle?: () => void
}
-const SEARCH_SHORTCUT = ['Mod', 'K']
-
-const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => {
+export function SnippetDetailTop({ expand = true, onToggle }: SnippetDetailTopProps) {
const { t } = useTranslation()
const router = useRouter()
@@ -26,9 +25,9 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
return (
{onToggle && (
- }
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
@@ -88,16 +87,16 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
>
{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}
- {SEARCH_SHORTCUT.map((key) => (
+ {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
))}
{onToggle && (
- }
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
@@ -105,5 +104,3 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
)
}
-
-export default SnippetDetailTop
diff --git a/web/app/components/snippets/components/snippet-header/run-mode.tsx b/web/app/components/snippets/components/snippet-header/run-mode.tsx
index 99b01547c18..8da34b4ac4a 100644
--- a/web/app/components/snippets/components/snippet-header/run-mode.tsx
+++ b/web/app/components/snippets/components/snippet-header/run-mode.tsx
@@ -4,8 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
-import { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/header/shortcuts.ts'
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
+import { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/hotkeys'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
import { useStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
diff --git a/web/app/components/snippets/create-snippet-dialog.tsx b/web/app/components/snippets/create-snippet-dialog.tsx
index b6648a4b319..af5f1bcf03d 100644
--- a/web/app/components/snippets/create-snippet-dialog.tsx
+++ b/web/app/components/snippets/create-snippet-dialog.tsx
@@ -1,14 +1,24 @@
'use client'
+import type { Hotkey } from '@tanstack/react-hotkeys'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { Button } from '@langgenius/dify-ui/button'
-import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
+import {
+ Dialog,
+ DialogBackdrop,
+ DialogCloseButton,
+ DialogPopup,
+ DialogPortal,
+ DialogTitle,
+} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
-import { useKeyPress } from 'ahooks'
-import { useCallback, useState } from 'react'
+import { useHotkey } from '@tanstack/react-hotkeys'
+import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
+const CREATE_SNIPPET_HOTKEY = 'Mod+Enter' satisfies Hotkey
+
export type CreateSnippetDialogPayload = {
name: string
description: string
@@ -39,7 +49,7 @@ const defaultGraph: SnippetCanvasData = {
viewport: { x: 0, y: 0, zoom: 1 },
}
-function CreateSnippetDialog({
+export function CreateSnippetDialog({
isOpen,
selectedGraph,
inputFields,
@@ -51,20 +61,21 @@ function CreateSnippetDialog({
initialValue,
}: CreateSnippetDialogProps) {
const { t } = useTranslation()
+ const popupRef = useRef(null)
const [name, setName] = useState(initialValue?.name ?? '')
const [description, setDescription] = useState(initialValue?.description ?? '')
- const resetForm = useCallback(() => {
+ function resetForm() {
setName('')
setDescription('')
- }, [])
+ }
- const handleClose = useCallback(() => {
+ function handleClose() {
resetForm()
onClose()
- }, [onClose, resetForm])
+ }
- const handleConfirm = useCallback(() => {
+ function handleConfirm() {
const trimmedName = name.trim()
const trimmedDescription = description.trim()
@@ -78,75 +89,79 @@ function CreateSnippetDialog({
}
onConfirm(payload)
- }, [description, inputFields, name, onConfirm, selectedGraph])
+ }
- useKeyPress(['meta.enter', 'ctrl.enter'], () => {
- if (!isOpen) return
-
- if (isSubmitting) return
-
- handleConfirm()
+ useHotkey(CREATE_SNIPPET_HOTKEY, handleConfirm, {
+ enabled: isOpen && !isSubmitting,
+ ignoreInputs: false,
+ preventDefault: false,
+ stopPropagation: false,
+ target: popupRef,
})
return (
<>
!open && handleClose()}>
-
-
+
+
+
+
-
-
- {title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })}
-
-
-
-
-
-
- {t(($) => $['snippet.nameLabel'], { ns: 'workflow' })}
-
-
setName(e.target.value)}
- placeholder={t(($) => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''}
- disabled={isSubmitting}
- autoFocus
- />
+
+
+ {title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })}
+
-
-
- {t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })}
+
+
+
+ {t(($) => $['snippet.nameLabel'], { ns: 'workflow' })}
+
+
setName(e.target.value)}
+ placeholder={t(($) => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''}
+ disabled={isSubmitting}
+ autoFocus
+ />
-
-
-
-
- {t(($) => $['operation.cancel'], { ns: 'common' })}
-
-
- {confirmText || t(($) => $['snippet.confirm'], { ns: 'workflow' })}
-
-
-
+
+
+ {t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })}
+
+
+
+
+
+
+ {t(($) => $['operation.cancel'], { ns: 'common' })}
+
+
+ {confirmText || t(($) => $['snippet.confirm'], { ns: 'workflow' })}
+
+
+
+
>
)
}
-
-export default CreateSnippetDialog
diff --git a/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx b/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx
index d398e08b504..ab7fad814e4 100644
--- a/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx
+++ b/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx
@@ -2,7 +2,7 @@ import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { useCallback, useState } from 'react'
import { getNodesBounds } from 'reactflow'
-import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
+import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import { PipelineInputVarType } from '@/models/pipeline'
import { useCreateSnippet } from './use-create-snippet'
diff --git a/web/app/components/workflow/__tests__/hotkeys.spec.ts b/web/app/components/workflow/__tests__/hotkeys.spec.ts
new file mode 100644
index 00000000000..cccde60f94d
--- /dev/null
+++ b/web/app/components/workflow/__tests__/hotkeys.spec.ts
@@ -0,0 +1,33 @@
+import { detectPlatform } from '@tanstack/react-hotkeys'
+import { shouldPreventWorkflowBrowserDefault } from '../hotkeys'
+
+const primaryModifier = detectPlatform() === 'mac' ? { metaKey: true } : { ctrlKey: true }
+
+function createKeyboardEvent(key: string, modifiers: KeyboardEventInit = {}) {
+ return new KeyboardEvent('keydown', { key, ...modifiers })
+}
+
+describe('workflow browser default hotkeys', () => {
+ it.each(['d', 'z', 'y', 's'])('matches the exact Mod+%s browser guard', (key) => {
+ expect(shouldPreventWorkflowBrowserDefault(createKeyboardEvent(key, primaryModifier))).toBe(
+ true,
+ )
+ })
+
+ it('matches the alternate redo hotkey', () => {
+ expect(
+ shouldPreventWorkflowBrowserDefault(
+ createKeyboardEvent('z', { ...primaryModifier, shiftKey: true }),
+ ),
+ ).toBe(true)
+ })
+
+ it('does not match plain or over-modified keys', () => {
+ expect(shouldPreventWorkflowBrowserDefault(createKeyboardEvent('s'))).toBe(false)
+ expect(
+ shouldPreventWorkflowBrowserDefault(
+ createKeyboardEvent('s', { ...primaryModifier, shiftKey: true }),
+ ),
+ ).toBe(false)
+ })
+})
diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx
index 4335452c836..9e2644693ba 100644
--- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx
+++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx
@@ -80,7 +80,7 @@ vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => {
})
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
- default: (props: {
+ CreateSnippetDialog: (props: {
isOpen: boolean
selectedGraph?: {
nodes: Node[]
diff --git a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx
index 4591980deac..b07623c377c 100644
--- a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx
+++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx
@@ -147,7 +147,7 @@ vi.mock('../run-and-history', () => ({
}))
vi.mock('../version-history-button', () => ({
- default: ({ onClick }: { onClick: () => void }) => (
+ VersionHistoryButton: ({ onClick }: { onClick: () => void }) => (
version-history
diff --git a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx
index c5699bc7607..cfeafa6168d 100644
--- a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx
+++ b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx
@@ -1,5 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
-import VersionHistoryButton from '../version-history-button'
+import { VersionHistoryButton } from '../version-history-button'
let mockTheme: 'light' | 'dark' = 'light'
const hotkeyRegistrations = vi.hoisted(
diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx
index bd2f74b5210..643265d6d4f 100644
--- a/web/app/components/workflow/header/header-in-normal.tsx
+++ b/web/app/components/workflow/header/header-in-normal.tsx
@@ -13,7 +13,7 @@ import GlobalVariableButton from './global-variable-button'
import OnlineUsers from './online-users'
import RunAndHistory from './run-and-history'
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
-import VersionHistoryButton from './version-history-button'
+import { VersionHistoryButton } from './version-history-button'
export type HeaderInNormalProps = {
components?: {
diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx
index ce571313e88..e3433d68ee9 100644
--- a/web/app/components/workflow/header/run-mode.tsx
+++ b/web/app/components/workflow/header/run-mode.tsx
@@ -19,7 +19,7 @@ import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options'
-import { TEST_RUN_MENU_HOTKEY } from './shortcuts'
+import { TEST_RUN_MENU_HOTKEY } from '../hotkeys'
import TestRunMenu, { TriggerType } from './test-run-menu'
type RunModeProps = {
diff --git a/web/app/components/workflow/header/shortcuts.ts b/web/app/components/workflow/header/shortcuts.ts
deleted file mode 100644
index 94637498c54..00000000000
--- a/web/app/components/workflow/header/shortcuts.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const TEST_RUN_MENU_HOTKEY = 'Alt+R'
diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx
index 66444a62323..1769477f835 100644
--- a/web/app/components/workflow/header/test-run-menu-helpers.tsx
+++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx
@@ -32,7 +32,9 @@ export const OptionRow = ({
{option.icon}
{option.name}
- {shortcutKey &&
}
+ {shortcutKey && (
+
+ )}
)
}
diff --git a/web/app/components/workflow/header/version-history-button.tsx b/web/app/components/workflow/header/version-history-button.tsx
index bec254e0ea1..e0571f3f7b3 100644
--- a/web/app/components/workflow/header/version-history-button.tsx
+++ b/web/app/components/workflow/header/version-history-button.tsx
@@ -1,21 +1,17 @@
-import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useHotkey } from '@tanstack/react-hotkeys'
-import * as React from 'react'
-import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
+import { VERSION_HISTORY_HOTKEY } from '../hotkeys'
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
-const VERSION_HISTORY_HOTKEY = 'Mod+Shift+H'
-
type VersionHistoryButtonProps = {
onClick: () => Promise
| unknown
}
-const PopupContent = React.memo(() => {
+function PopupContent() {
const { t } = useTranslation()
return (
@@ -25,20 +21,15 @@ const PopupContent = React.memo(() => {
)
-})
+}
-PopupContent.displayName = 'PopupContent'
-
-const VersionHistoryButton: FC = ({ onClick }) => {
+export function VersionHistoryButton({ onClick }: VersionHistoryButtonProps) {
const { theme } = useTheme()
- const handleViewVersionHistory = useCallback(async () => {
- await onClick?.()
- }, [onClick])
useHotkey(
VERSION_HISTORY_HOTKEY,
() => {
- void handleViewVersionHistory()
+ void onClick()
},
{
ignoreInputs: true,
@@ -54,7 +45,7 @@ const VersionHistoryButton: FC = ({ onClick }) => {
'rounded-lg border border-transparent p-2',
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
)}
- onClick={handleViewVersionHistory}
+ onClick={onClick}
>
@@ -66,5 +57,3 @@ const VersionHistoryButton: FC = ({ onClick }) => {
)
}
-
-export default VersionHistoryButton
diff --git a/web/app/components/workflow/hotkeys.ts b/web/app/components/workflow/hotkeys.ts
new file mode 100644
index 00000000000..f3288ab0ee7
--- /dev/null
+++ b/web/app/components/workflow/hotkeys.ts
@@ -0,0 +1,19 @@
+import type { Hotkey } from '@tanstack/react-hotkeys'
+import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
+import { WORKFLOW_CANVAS_SHORTCUTS } from './shortcuts/definitions'
+
+export const TEST_RUN_MENU_HOTKEY = 'Alt+R' satisfies Hotkey
+export const VERSION_HISTORY_HOTKEY = 'Mod+Shift+H' satisfies Hotkey
+
+const WORKFLOW_BROWSER_DEFAULT_GUARD_HOTKEYS = [
+ ...WORKFLOW_CANVAS_SHORTCUTS['workflow.duplicate'].hotkeys,
+ ...WORKFLOW_CANVAS_SHORTCUTS['workflow.undo'].hotkeys,
+ ...WORKFLOW_CANVAS_SHORTCUTS['workflow.redo'].hotkeys,
+ 'Mod+S',
+] satisfies readonly Hotkey[]
+
+export function shouldPreventWorkflowBrowserDefault(event: KeyboardEvent) {
+ return WORKFLOW_BROWSER_DEFAULT_GUARD_HOTKEYS.some((hotkey) =>
+ matchesKeyboardEvent(event, hotkey),
+ )
+}
diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx
index 1883ef8c716..579c1de9910 100644
--- a/web/app/components/workflow/index.tsx
+++ b/web/app/components/workflow/index.tsx
@@ -72,6 +72,7 @@ import {
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
import { useWorkflowComment } from './hooks/use-workflow-comment'
import { useWorkflowSearch } from './hooks/use-workflow-search'
+import { shouldPreventWorkflowBrowserDefault } from './hotkeys'
import CustomNode from './nodes'
import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type'
import CustomDataSourceEmptyNode from './nodes/data-source-empty'
@@ -446,10 +447,7 @@ export const Workflow: FC = memo(
}, [handleSyncWorkflowDraftWhenPageClose, handleBeforeUnload])
useEventListener('keydown', (e) => {
- if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) e.preventDefault()
- if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) e.preventDefault()
- if ((e.key === 'y' || e.key === 'Y') && (e.ctrlKey || e.metaKey)) e.preventDefault()
- if ((e.key === 's' || e.key === 'S') && (e.ctrlKey || e.metaKey)) e.preventDefault()
+ if (shouldPreventWorkflowBrowserDefault(e)) e.preventDefault()
})
useEventListener('mousemove', (e) => {
const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()
diff --git a/web/app/components/workflow/node-actions-menu/shared.tsx b/web/app/components/workflow/node-actions-menu/shared.tsx
index 9872ba1dac9..b4689deb517 100644
--- a/web/app/components/workflow/node-actions-menu/shared.tsx
+++ b/web/app/components/workflow/node-actions-menu/shared.tsx
@@ -1,4 +1,3 @@
-import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
import type { ReactNode } from 'react'
import type { WorkflowCanvasShortcutId } from '@/app/components/workflow/shortcuts/definitions'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
@@ -9,17 +8,15 @@ export const NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME = `${NODE_ACTIONS_MENU_ITE
export function NodeActionsMenuItemContent({
children,
- hotkey,
shortcut,
}: {
children: ReactNode
- hotkey?: RegisterableHotkey | (string & {})
shortcut?: WorkflowCanvasShortcutId
}) {
return (
<>
{children}
- {(shortcut || hotkey) && }
+ {shortcut && }
>
)
}
diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx
index 3412cf9e9c2..7818413b4dd 100644
--- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx
+++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx
@@ -1,4 +1,5 @@
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
+import type { Hotkey } from '@tanstack/react-hotkeys'
import type { EditableOutputConfig, EditingState, OutputDraft } from './utils'
import { Button } from '@langgenius/dify-ui/button'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
@@ -18,7 +19,8 @@ import {
OUTPUT_NAME_PATTERN_SOURCE,
} from './utils'
-const CONFIRM_HOTKEY = 'Mod+Enter'
+const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
+const CONFIRM_HOTKEY = 'Mod+Enter' satisfies Hotkey
function ConfirmHotkeyHint() {
const displayKeys = formatForDisplay(CONFIRM_HOTKEY, { separatorToken: ' ' })
@@ -70,8 +72,11 @@ export function OutputEditCard({
if (confirmDisabled) return
onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state)
}
- useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false })
- useHotkey('Escape', onCancel, { target: editorRef, ignoreInputs: false })
+ useHotkey(CONFIRM_HOTKEY, handleConfirm, {
+ target: editorRef,
+ ignoreInputs: false,
+ })
+ useHotkey(CANCEL_HOTKEY, onCancel, { target: editorRef, ignoreInputs: false })
return (
)
}
-
-export default React.memo(AdvancedActions)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx
index b8c968e2329..95c41440179 100644
--- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx
@@ -13,7 +13,7 @@ import { ArrayType, Type } from '../../../../types'
import { useMittContext } from '../context'
import { useVisualEditorStore } from '../store'
import Actions from './actions'
-import AdvancedActions from './advanced-actions'
+import { AdvancedActions } from './advanced-actions'
import AdvancedOptions from './advanced-options'
import AutoWidthInput from './auto-width-input'
import RequiredSwitch from './required-switch'
diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx
index a88e6db22af..cb50560852b 100644
--- a/web/app/components/workflow/panel-contextmenu.tsx
+++ b/web/app/components/workflow/panel-contextmenu.tsx
@@ -8,7 +8,6 @@ import {
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FlowType } from '@/types/common'
-import { TEST_RUN_MENU_HOTKEY } from './header/shortcuts'
import {
useDSL,
useIsChatMode,
@@ -17,6 +16,7 @@ import {
useWorkflowStartRun,
} from './hooks'
import { useHooksStore } from './hooks-store'
+import { TEST_RUN_MENU_HOTKEY } from './hotkeys'
import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars'
import AddBlock from './operator/add-block'
import { useOperator } from './operator/hooks'
diff --git a/web/app/components/workflow/shortcuts/definitions.ts b/web/app/components/workflow/shortcuts/definitions.ts
index 1e0eb5a1b89..545c5a020b1 100644
--- a/web/app/components/workflow/shortcuts/definitions.ts
+++ b/web/app/components/workflow/shortcuts/definitions.ts
@@ -1,4 +1,4 @@
-import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
+import type { Hotkey, IndividualKey } from '@tanstack/react-hotkeys'
export type WorkflowCanvasShortcutId =
| 'workflow.delete'
@@ -26,18 +26,26 @@ export type WorkflowCanvasHotkeyMeta = {
description: string
}
-export type WorkflowCanvasShortcutDefinition = {
+type WorkflowCanvasShortcutDefinitionBase = {
id: WorkflowCanvasShortcutId
- hotkeys: readonly RegisterableHotkey[]
- displayHotkey?: RegisterableHotkey | (string & {})
name: string
description: string
}
-export const WORKFLOW_CANVAS_SHORTCUTS: Record<
- WorkflowCanvasShortcutId,
- WorkflowCanvasShortcutDefinition
-> = {
+export type WorkflowCanvasHotkeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
+ hotkeys: readonly Hotkey[]
+ displayHotkey?: Hotkey
+}
+
+type WorkflowCanvasHoldKeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
+ holdKey: IndividualKey
+}
+
+type WorkflowCanvasShortcutDefinition =
+ | WorkflowCanvasHotkeyDefinition
+ | WorkflowCanvasHoldKeyDefinition
+
+export const WORKFLOW_CANVAS_SHORTCUTS = {
'workflow.delete': {
id: 'workflow.delete',
hotkeys: ['Delete', 'Backspace'],
@@ -139,16 +147,19 @@ export const WORKFLOW_CANVAS_SHORTCUTS: Record<
},
'workflow.dim-other-nodes': {
id: 'workflow.dim-other-nodes',
- hotkeys: [{ key: 'Shift', shift: true }],
- displayHotkey: 'Shift',
+ holdKey: 'Shift',
name: 'Dim other nodes',
description: 'Dim nodes outside the current workflow selection',
},
-}
+} as const satisfies Record
-export const getWorkflowCanvasShortcutDisplayHotkey = (
+export const getWorkflowCanvasShortcutDisplayKey = (
id: WorkflowCanvasShortcutId,
-): RegisterableHotkey | (string & {}) => {
+): Hotkey | IndividualKey => {
const shortcut = WORKFLOW_CANVAS_SHORTCUTS[id]
- return shortcut.displayHotkey ?? shortcut.hotkeys[0]!
+
+ if ('displayHotkey' in shortcut && shortcut.displayHotkey) return shortcut.displayHotkey
+ if ('hotkeys' in shortcut) return shortcut.hotkeys[0]
+
+ return shortcut.holdKey
}
diff --git a/web/app/components/workflow/shortcuts/shortcut-kbd.tsx b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx
index 4e10285d948..4feb7acd1d8 100644
--- a/web/app/components/workflow/shortcuts/shortcut-kbd.tsx
+++ b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx
@@ -1,14 +1,17 @@
import type { KbdColor } from '@langgenius/dify-ui/kbd'
-import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys'
+import type { FormatDisplayOptions, Hotkey, IndividualKey } from '@tanstack/react-hotkeys'
import type { WorkflowCanvasShortcutId } from './definitions'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
-import { getWorkflowCanvasShortcutDisplayHotkey } from './definitions'
+import { getWorkflowCanvasShortcutDisplayKey } from './definitions'
-type ShortcutKbdProps = {
- shortcut?: WorkflowCanvasShortcutId
- hotkey?: RegisterableHotkey | (string & {})
+type ShortcutKbdSource =
+ | { shortcut: WorkflowCanvasShortcutId; hotkey?: never; displayKey?: never }
+ | { shortcut?: never; hotkey: Hotkey; displayKey?: never }
+ | { shortcut?: never; hotkey?: never; displayKey: string }
+
+type ShortcutKbdProps = ShortcutKbdSource & {
className?: string
textColor?: 'default' | 'secondary'
bgColor?: KbdColor
@@ -16,13 +19,11 @@ type ShortcutKbdProps = {
}
const getDisplayKeys = (
- hotkey: RegisterableHotkey | (string & {}),
+ hotkey: Hotkey | IndividualKey,
platform?: FormatDisplayOptions['platform'],
) => {
const displayOptions = platform ? { platform } : undefined
- if (typeof hotkey !== 'string') return [formatForDisplay(hotkey, displayOptions)]
-
return hotkey
.split('+')
.filter(Boolean)
@@ -32,17 +33,23 @@ const getDisplayKeys = (
export const ShortcutKbd = ({
shortcut,
hotkey,
+ displayKey,
className,
textColor = 'default',
bgColor = 'gray',
platform,
}: ShortcutKbdProps) => {
- const displayHotkey =
- hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayHotkey(shortcut) : undefined)
+ const shortcutDisplayKey =
+ hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayKey(shortcut) : undefined)
- if (!displayHotkey) return null
+ const displayOptions = platform ? { platform } : undefined
+ const displayKeys = displayKey
+ ? [formatForDisplay(displayKey, displayOptions)]
+ : shortcutDisplayKey
+ ? getDisplayKeys(shortcutDisplayKey, platform)
+ : []
- const displayKeys = getDisplayKeys(displayHotkey, platform)
+ if (!displayKeys.length) return null
return (
diff --git a/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts
index 1aa33fb0472..3fe4d9603ec 100644
--- a/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts
+++ b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts
@@ -1,5 +1,5 @@
import type { HotkeyCallback, UseHotkeyDefinition, UseHotkeyOptions } from '@tanstack/react-hotkeys'
-import type { WorkflowCanvasHotkeyMeta, WorkflowCanvasShortcutDefinition } from './definitions'
+import type { WorkflowCanvasHotkeyDefinition, WorkflowCanvasHotkeyMeta } from './definitions'
import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useReactFlow } from 'reactflow'
@@ -29,7 +29,7 @@ const isInputLikeElement = (element: Element | null) => {
}
const toHotkeyDefinitions = (
- shortcut: WorkflowCanvasShortcutDefinition,
+ shortcut: WorkflowCanvasHotkeyDefinition,
callback: HotkeyCallback,
options?: UseHotkeyOptions,
): UseHotkeyDefinition[] => {
@@ -68,7 +68,7 @@ export const useWorkflowHotkeys = (): void => {
const { handleLayout } = useWorkflowOrganize()
const { zoomTo, getZoom, fitView, getNodes } = useReactFlow()
- const isShiftHeld = useKeyHold('Shift')
+ const isShiftHeld = useKeyHold(WORKFLOW_CANVAS_SHORTCUTS['workflow.dim-other-nodes'].holdKey)
const shiftDimmedRef = useRef(false)
const undimAllNodesRef = useRef(undimAllNodes)
undimAllNodesRef.current = undimAllNodes
diff --git a/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx
index 5232ebc5418..72c41b4e1b6 100644
--- a/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx
+++ b/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx
@@ -1,6 +1,6 @@
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
import type { GenerateWorkflowStreamCallbacks } from '@/service/workflow-generator'
-import { render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import WorkflowGeneratorModal from '../index'
import { useWorkflowGeneratorStore } from '../store'
@@ -93,6 +93,19 @@ describe('WorkflowGeneratorModal', () => {
expect(instruction).toHaveValue('Summarize a URL')
})
+
+ it('should generate from the instruction shortcut', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ const instruction = screen.getByRole('textbox', {
+ name: /workflowGenerator\.instruction/i,
+ })
+ await user.type(instruction, 'Summarize a URL')
+ fireEvent.keyDown(instruction, { key: 'Enter', ctrlKey: true })
+
+ await waitFor(() => expect(mockGenerateWorkflowStream).toHaveBeenCalledOnce())
+ })
})
describe('Generation errors', () => {
diff --git a/web/app/components/workflow/workflow-generator/index.tsx b/web/app/components/workflow/workflow-generator/index.tsx
index 76b605bdb60..00dab89f6cd 100644
--- a/web/app/components/workflow/workflow-generator/index.tsx
+++ b/web/app/components/workflow/workflow-generator/index.tsx
@@ -1,5 +1,6 @@
'use client'
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
+import type { Hotkey } from '@tanstack/react-hotkeys'
import type { SelectorParam, TFunction } from 'i18next'
import type { GeneratedGraph } from './types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
@@ -23,6 +24,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgeni
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
+import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -63,6 +65,7 @@ const FE_TIMEOUT_MS = 90_000
// Mirrors the backend's instruction/ideal-output cap on /workflow-generate —
// keeping the limit client-side turns an opaque 400 into a visible input stop.
const MAX_INSTRUCTION_LENGTH = 10_000
+const WORKFLOW_GENERATOR_SUBMIT_HOTKEY = 'Mod+Enter' satisfies Hotkey
// A single structured generation error. Mirrors the backend ``errors[]`` entry
// (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel
@@ -615,9 +618,7 @@ function WorkflowGeneratorModal() {
value={instruction}
onValueChange={setInstruction}
onKeyDown={(e) => {
- // ⌘/Ctrl+Enter generates — the journey starts keyboard-first in
- // the palette, so let it finish without reaching for the mouse.
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
+ if (matchesKeyboardEvent(e.nativeEvent, WORKFLOW_GENERATOR_SUBMIT_HOTKEY)) {
e.preventDefault()
if (!isLoading) onGenerate()
}
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx
index 78ea268048d..319020006a2 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx
@@ -5,7 +5,7 @@ import type {
AgentReferencingWorkflowResponse,
AgentReferencingWorkflowsResponse,
} from '@dify/contracts/api/console/agent/types.gen'
-import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
+import type { Hotkey } from '@tanstack/react-hotkeys'
import { Button } from '@langgenius/dify-ui/button'
import { Collapsible, CollapsiblePanel } from '@langgenius/dify-ui/collapsible'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
@@ -25,7 +25,7 @@ import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/client'
import { AgentPublishImpactDetails } from './publish-impact-details'
-const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey
+const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
type AgentConfigurePublishState = 'draft' | 'publishing' | 'published' | 'unpublished'
diff --git a/web/features/agent-v2/agent-detail/navigation.tsx b/web/features/agent-v2/agent-detail/navigation.tsx
index 38d57e7ab8a..a1f6531cbef 100644
--- a/web/features/agent-v2/agent-detail/navigation.tsx
+++ b/web/features/agent-v2/agent-detail/navigation.tsx
@@ -12,11 +12,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import NavLink from '@/app/components/app-sidebar/nav-link'
-import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
+import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
+import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
@@ -39,8 +40,6 @@ type AgentDetailNavItem = {
activeIcon: NavIcon
}
-const SEARCH_SHORTCUT = ['Mod', 'K']
-
const createAgentNavIcon = (iconClassName: string) => {
function AgentNavIcon({ className }: ComponentProps<'svg'>) {
return
@@ -92,9 +91,9 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
return (
{onToggle && (
-
}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
@@ -145,16 +144,16 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
>
{tApp(($) => $['gotoAnything.quickAction'])}
- {SEARCH_SHORTCUT.map((key) => (
+ {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
))}
{onToggle && (
-
}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
diff --git a/web/features/deployments/detail/deployment-sidebar.tsx b/web/features/deployments/detail/deployment-sidebar.tsx
index 07fdcedc7f9..1086d4fe41d 100644
--- a/web/features/deployments/detail/deployment-sidebar.tsx
+++ b/web/features/deployments/detail/deployment-sidebar.tsx
@@ -11,11 +11,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import NavLink from '@/app/components/app-sidebar/nav-link'
-import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import Divider from '@/app/components/base/divider'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
+import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
+import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import { DeploymentActionsMenu } from '../deployment-actions'
@@ -78,8 +79,6 @@ const DEPLOYMENT_TABS: TabDef[] = [
{ key: 'api-tokens', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
]
-const SEARCH_SHORTCUT = ['Mod', 'K']
-
function DeploymentIcon({ expand }: { expand: boolean }) {
return (
{onToggle && (
- }
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
@@ -251,16 +250,16 @@ export function DeploymentDetailTop({
>
{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}
- {SEARCH_SHORTCUT.map((key) => (
+ {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
{formatForDisplay(key)}
))}
{onToggle && (
- }
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>