From e134c947c157565aaa02faa2975cd9e24bc54abb Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Wed, 15 Jul 2026 10:22:40 +0800
Subject: [PATCH] refactor(web): standardize hotkey ownership and typing
(#38960)
---
.../skills/how-to-write-component/SKILL.md | 14 ++
oxlint-suppressions.json | 26 ----
.../__tests__/app-detail-top.spec.tsx | 29 +---
.../__tests__/dataset-detail-top.spec.tsx | 29 +---
.../__tests__/toggle-button.spec.tsx | 40 -----
.../app-sidebar/app-detail-sidebar.tsx | 2 +-
.../components/app-sidebar/app-detail-top.tsx | 19 +--
.../app-sidebar/dataset-detail-sidebar.tsx | 2 +-
.../app-sidebar/dataset-detail-top.tsx | 19 +--
.../snippet-info/__tests__/dropdown.spec.tsx | 2 +-
.../app-sidebar/snippet-info/dropdown.tsx | 2 +-
.../components/app-sidebar/toggle-button.tsx | 76 ---------
.../app-publisher/__tests__/index.spec.tsx | 1 -
.../app-publisher/__tests__/sections.spec.tsx | 4 -
.../components/app/app-publisher/hotkeys.ts | 3 +
.../components/app/app-publisher/index.tsx | 8 +-
.../components/app/app-publisher/sections.tsx | 5 +-
.../components/app/create-app-modal/index.tsx | 16 +-
.../__tests__/index.spec.tsx | 3 +-
.../app/create-from-dsl-modal/index.tsx | 38 +++--
.../app/create-from-dsl-modal/types.ts | 7 +
.../tab/__tests__/index.spec.tsx | 2 +-
.../create-from-dsl-modal/tab/index.tsx | 2 +-
.../detail/__tests__/new-segment.spec.tsx | 2 +-
.../__tests__/child-segment-detail.spec.tsx | 2 +-
.../__tests__/new-child-segment.spec.tsx | 2 +-
.../__tests__/segment-detail.spec.tsx | 2 +-
.../detail/completed/child-segment-detail.tsx | 2 +-
.../common/__tests__/action-buttons.spec.tsx | 2 +-
.../completed/common/action-buttons.tsx | 30 ++--
.../detail/completed/new-child-segment.tsx | 2 +-
.../detail/completed/segment-detail.tsx | 2 +-
.../datasets/documents/detail/new-segment.tsx | 2 +-
.../detail-sidebar/__tests__/index.spec.tsx | 21 ++-
.../__tests__/toggle-button.spec.tsx | 29 ++++
web/app/components/detail-sidebar/hotkeys.ts | 3 +
web/app/components/detail-sidebar/index.tsx | 29 ++--
.../detail-sidebar/toggle-button.tsx | 62 ++++++++
.../explore/create-app-modal/index.tsx | 7 +-
web/app/components/goto-anything/hotkeys.ts | 3 +
web/app/components/goto-anything/index.tsx | 7 +-
.../main-nav/components/search-button.tsx | 5 +-
.../__tests__/index.spec.tsx | 10 +-
.../__tests__/run-mode.spec.tsx | 41 ++++-
.../components/rag-pipeline-header/hotkeys.ts | 4 +
.../components/rag-pipeline-header/index.tsx | 2 +-
.../publisher/__tests__/index.spec.tsx | 9 +-
.../publisher/__tests__/popup.spec.tsx | 2 +-
.../rag-pipeline-header/publisher/index.tsx | 2 +-
.../rag-pipeline-header/publisher/popup.tsx | 19 ++-
.../rag-pipeline-header/run-mode.tsx | 25 +--
.../snippet-list/__tests__/index.spec.tsx | 2 +-
.../snippet-list/components/snippet-card.tsx | 2 +-
.../components/snippet-create-button.tsx | 2 +-
.../__tests__/create-snippet-dialog.spec.tsx | 50 +++++-
.../components/snippet-detail-sidebar.tsx | 2 +-
.../components/snippet-detail-top.tsx | 19 +--
.../components/snippet-header/run-mode.tsx | 2 +-
.../snippets/create-snippet-dialog.tsx | 147 ++++++++++--------
.../use-create-snippet-from-selection.tsx | 2 +-
.../workflow/__tests__/hotkeys.spec.ts | 33 ++++
.../__tests__/selection-contextmenu.spec.tsx | 2 +-
.../header/__tests__/header-layouts.spec.tsx | 2 +-
.../__tests__/version-history-button.spec.tsx | 2 +-
.../workflow/header/header-in-normal.tsx | 2 +-
.../components/workflow/header/run-mode.tsx | 2 +-
.../components/workflow/header/shortcuts.ts | 1 -
.../workflow/header/test-run-menu-helpers.tsx | 4 +-
.../header/version-history-button.tsx | 23 +--
web/app/components/workflow/hotkeys.ts | 19 +++
web/app/components/workflow/index.tsx | 6 +-
.../workflow/node-actions-menu/shared.tsx | 5 +-
.../agent-output-variables/edit-card.tsx | 11 +-
.../__tests__/advanced-actions.spec.tsx | 65 ++++++++
.../edit-card/advanced-actions.tsx | 9 +-
.../visual-editor/edit-card/index.tsx | 2 +-
.../components/workflow/panel-contextmenu.tsx | 2 +-
.../workflow/shortcuts/definitions.ts | 39 +++--
.../workflow/shortcuts/shortcut-kbd.tsx | 31 ++--
.../shortcuts/use-workflow-hotkeys.ts | 6 +-
.../__tests__/index.spec.tsx | 15 +-
.../workflow/workflow-generator/index.tsx | 7 +-
.../orchestrate/publish-bar/index.tsx | 4 +-
.../agent-v2/agent-detail/navigation.tsx | 15 +-
.../deployments/detail/deployment-sidebar.tsx | 15 +-
85 files changed, 686 insertions(+), 541 deletions(-)
delete mode 100644 web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx
delete mode 100644 web/app/components/app-sidebar/toggle-button.tsx
create mode 100644 web/app/components/app/app-publisher/hotkeys.ts
create mode 100644 web/app/components/app/create-from-dsl-modal/types.ts
create mode 100644 web/app/components/detail-sidebar/__tests__/toggle-button.spec.tsx
create mode 100644 web/app/components/detail-sidebar/hotkeys.ts
create mode 100644 web/app/components/detail-sidebar/toggle-button.tsx
create mode 100644 web/app/components/goto-anything/hotkeys.ts
create mode 100644 web/app/components/rag-pipeline/components/rag-pipeline-header/hotkeys.ts
create mode 100644 web/app/components/workflow/__tests__/hotkeys.spec.ts
delete mode 100644 web/app/components/workflow/header/shortcuts.ts
create mode 100644 web/app/components/workflow/hotkeys.ts
create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/__tests__/advanced-actions.spec.tsx
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
- }) => (
-
- ),
-}))
+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
- }) => (
-
- ),
-}))
+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()}>
-
-
+
{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 (