({
+ useWorkflowInteractions: () => ({ handleCancelDebugAndPreviewPanel: vi.fn() }),
+}))
+vi.mock('@/app/components/workflow/hooks/use-workflow-run', () => ({
+ useWorkflowRun: () => ({ handleRun: vi.fn() }),
+}))
+vi.mock('@/app/components/workflow/hooks/use-nodes-interactions-without-sync', () => ({
+ useNodesInteractionsWithoutSync: () => ({ handleNodeCancelRunningStatus: vi.fn() }),
+}))
+vi.mock('@/app/components/workflow/hooks/use-edges-interactions-without-sync', () => ({
+ useEdgesInteractionsWithoutSync: () => ({ handleEdgeCancelRunningStatus: vi.fn() }),
+}))
+vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({
+ useCheckInputsForms: () => ({ checkInputsForm: vi.fn() }),
+}))
+vi.mock('@/app/components/workflow/panel/debug-and-preview/chat-wrapper', () => ({
+ default: () => null,
+}))
+vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ default: () => null }))
+vi.mock('@/app/components/workflow/run/result-panel', () => ({ default: () => null }))
+vi.mock('@/app/components/workflow/run/result-text', () => ({ default: () => null }))
+vi.mock('@/app/components/workflow/panel/inputs-panel', () => ({ default: () => null }))
+vi.mock('@/app/components/workflow/panel/env-panel', () => ({ default: () => null }))
+// Node details do not own the preview width limit. Node-panel compression is
+// covered separately at the real BasePanel boundary.
+vi.mock('@/app/components/workflow/nodes', () => ({
+ Panel: () =>
Selected node details
,
+}))
+
+function SelectedNodePanel({ children }: { children: ReactNode }) {
+ const reactFlowStore = useStoreApi()
+ useEffect(() => {
+ reactFlowStore.getState().setNodes([createNode({ data: { selected: true } })])
+ }, [reactFlowStore])
+ return (
+ <>
+
+
+ >
+ )
+}
+
+function renderPanels(children: ReactNode) {
+ return renderWorkflowComponent(
+
+ {children}
+ ,
+ {
+ initialStoreState: { workflowCanvasWidth: 1400, nodePanelWidth: 600, previewPanelWidth: 400 },
+ },
+ )
+}
+
+describe('preview width limits inside the workflow Panel', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it.each([
+ ['workflow run',
],
+ ['snippet run',
],
+ ['chat debug',
],
+ ])(
+ '%s exposes the reachable maximum and restores canvas space after deselection',
+ async (_name, content) => {
+ const user = userEvent.setup()
+ renderPanels(content)
+ const handle = screen.getByRole('separator')
+ expect(handle).toHaveAttribute('aria-valuemax', '600')
+ await user.tab()
+ await user.tab()
+ expect(handle).toHaveFocus()
+ await user.keyboard('{End}{ArrowLeft}')
+ expect(handle).toHaveAttribute('aria-valuenow', '600')
+ const panel = document.getElementById(handle.getAttribute('aria-controls')!)!
+ expect(panel).toHaveStyle({ width: '600px' })
+
+ await user.click(screen.getByRole('button', { name: 'Deselect node' }))
+ expect(screen.queryByText('Selected node details')).not.toBeInTheDocument()
+ expect(handle).toHaveAttribute('aria-valuemax', '1000')
+ await user.tab()
+ await user.keyboard('{End}')
+ expect(handle).toHaveAttribute('aria-valuenow', '1000')
+ expect(panel).toHaveStyle({ width: '1000px' })
+ },
+ )
+
+ it('allows the debug panel to grow by pointer while the selected node panel is wider than its minimum', async () => {
+ const user = userEvent.setup()
+ renderPanels(
)
+ const handle = screen.getByRole('separator')
+ await user.pointer([
+ { keys: '[MouseLeft>]', target: handle, coords: { clientX: 1000 } },
+ { target: handle, coords: { clientX: 800 } },
+ { keys: '[/MouseLeft]', target: handle },
+ ])
+ await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '600'))
+ expect(handle).toHaveAttribute('aria-valuemax', '600')
+ })
+
+ it.each([
+ ['workflow run',
],
+ ['snippet run',
],
+ ['chat debug',
],
+ ])(
+ '%s keeps its size and ARIA range consistent when the canvas shrinks without a selected node',
+ async (_name, content) => {
+ const user = userEvent.setup()
+ const { store } = renderPanels(content)
+ await user.click(screen.getByRole('button', { name: 'Deselect node' }))
+ const handle = screen.getByRole('separator')
+ await user.tab()
+ expect(handle).toHaveFocus()
+ await user.keyboard('{End}')
+ expect(handle).toHaveAttribute('aria-valuenow', '1000')
+
+ act(() => store.getState().setWorkflowCanvasWidth(1000))
+
+ await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '600'))
+ expect(handle).toHaveAttribute('aria-valuemax', '600')
+ const panel = document.getElementById(handle.getAttribute('aria-controls')!)!
+ expect(panel).toHaveStyle({ width: '600px' })
+ expect(handle).toHaveFocus()
+ await user.keyboard('{ArrowLeft}')
+ expect(handle).toHaveAttribute('aria-valuenow', '600')
+ await user.keyboard('{ArrowRight}')
+ expect(handle).toHaveAttribute('aria-valuenow', '592')
+ },
+ )
+})
diff --git a/web/app/components/workflow/panel/__tests__/panel-width.spec.ts b/web/app/components/workflow/panel/__tests__/panel-width.spec.ts
new file mode 100644
index 00000000000..bb0c0809e32
--- /dev/null
+++ b/web/app/components/workflow/panel/__tests__/panel-width.spec.ts
@@ -0,0 +1,36 @@
+import { getPreviewPanelMaxWidth } from '../panel-width'
+
+describe('getPreviewPanelMaxWidth', () => {
+ it.each([undefined, 0])(
+ 'uses the caller fallback before canvas width is measured (%s)',
+ (canvasWidth) => {
+ expect(getPreviewPanelMaxWidth(canvasWidth, false)).toBe(1024)
+ expect(getPreviewPanelMaxWidth(canvasWidth, true, 720)).toBe(720)
+ },
+ )
+
+ it.each([
+ { hasSelectedNode: false, expected: 1000 },
+ { hasSelectedNode: true, expected: 600 },
+ ])(
+ 'reserves space for the canvas and an open node panel (selected: $hasSelectedNode)',
+ ({ hasSelectedNode, expected }) => {
+ expect(getPreviewPanelMaxWidth(1400, hasSelectedNode)).toBe(expected)
+ expect(getPreviewPanelMaxWidth(1400, hasSelectedNode, 720)).toBe(expected)
+ },
+ )
+
+ it.each([
+ { canvasWidth: 600, hasSelectedNode: false, expected: 400 },
+ { canvasWidth: 800, hasSelectedNode: false, expected: 400 },
+ { canvasWidth: 801, hasSelectedNode: false, expected: 401 },
+ { canvasWidth: 1000, hasSelectedNode: true, expected: 400 },
+ { canvasWidth: 1200, hasSelectedNode: true, expected: 400 },
+ { canvasWidth: 1201, hasSelectedNode: true, expected: 401 },
+ ])(
+ 'keeps the preview usable on a $canvasWidth px canvas (selected: $hasSelectedNode)',
+ ({ canvasWidth, hasSelectedNode, expected }) => {
+ expect(getPreviewPanelMaxWidth(canvasWidth, hasSelectedNode)).toBe(expected)
+ },
+ )
+})
diff --git a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx
index b13fa7ee6da..ab7b2941323 100644
--- a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx
+++ b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx
@@ -5,15 +5,21 @@ import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
+import { ReactFlowProvider } from 'reactflow'
import {
createNodeTracing,
createWorkflowRunningData,
} from '@/app/components/workflow/__tests__/fixtures'
-import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
+import { renderWorkflowComponent as renderWithWorkflowStore } from '@/app/components/workflow/__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { submitHumanInputForm } from '@/service/workflow'
import WorkflowPreview from '../workflow-preview'
+const renderWorkflowComponent = (
+ ui: Parameters
[0],
+ options?: Parameters[1],
+) => renderWithWorkflowStore({ui}, options)
+
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
vi.mock('copy-to-clipboard', () => ({
@@ -175,6 +181,22 @@ describe('WorkflowPreview', () => {
})
})
+ it('resizes the run panel with the keyboard within the available canvas width', async () => {
+ const user = userEvent.setup()
+ renderWorkflowComponent(, {
+ initialStoreState: { previewPanelWidth: 480, workflowCanvasWidth: 1000 },
+ })
+ await user.tab()
+ const handle = screen.getByRole('separator', { name: 'workflow.singleRun.testRun' })
+ expect(handle).toHaveFocus()
+ await user.keyboard('{ArrowLeft}{Shift>}{ArrowLeft}{/Shift}')
+ expect(handle).toHaveAttribute('aria-valuenow', '520')
+ await user.keyboard('{End}{ArrowLeft}')
+ expect(handle).toHaveAttribute('aria-valuenow', '600')
+ await user.keyboard('{Home}{ArrowRight}')
+ expect(handle).toHaveAttribute('aria-valuenow', '400')
+ })
+
it('should keep the input tab active, switch to result after running, and close the preview panel', async () => {
const user = userEvent.setup()
renderWorkflowComponent(, {
diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/debug-and-preview.spec.tsx b/web/app/components/workflow/panel/debug-and-preview/__tests__/debug-and-preview.spec.tsx
index 1bf37f13372..e02e8694dc9 100644
--- a/web/app/components/workflow/panel/debug-and-preview/__tests__/debug-and-preview.spec.tsx
+++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/debug-and-preview.spec.tsx
@@ -1,5 +1,5 @@
import type { Ref } from 'react'
-import { screen } from '@testing-library/react'
+import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useImperativeHandle } from 'react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
@@ -47,10 +47,6 @@ vi.mock('../../../nodes/_base/hooks/use-resize-panel', () => ({
}),
}))
-vi.mock('../../../persistence/local-storage-options', () => ({
- useSetDebugPreviewPanelWidth: () => vi.fn(),
-}))
-
vi.mock('../chat-wrapper', () => ({
default: function MockChatWrapper({ ref }: { ref: Ref<{ handleRestart: () => void }> }) {
useImperativeHandle(ref, () => ({ handleRestart: mockHandleRestart }))
@@ -63,6 +59,23 @@ describe('DebugAndPreview', () => {
vi.clearAllMocks()
})
+ it('resizes from the left edge with the keyboard and persists the width', async () => {
+ const user = userEvent.setup()
+ renderWorkflowComponent(, {
+ initialStoreState: { previewPanelWidth: 480, workflowCanvasWidth: 1000 },
+ })
+ await user.tab()
+ const handle = screen.getByRole('separator', { name: 'workflow.common.debugAndPreview' })
+ expect(handle).toHaveFocus()
+ await user.keyboard('{ArrowLeft}')
+ expect(handle).toHaveAttribute('aria-valuenow', '488')
+ await waitFor(() => expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('488'))
+ await user.keyboard('{End}{ArrowLeft}')
+ expect(handle).toHaveAttribute('aria-valuenow', '600')
+ await user.keyboard('{Home}')
+ expect(handle).toHaveAttribute('aria-valuenow', '400')
+ })
+
it('exposes and invokes the restart action by name', async () => {
const user = userEvent.setup()
renderWorkflowComponent(, {
diff --git a/web/app/components/workflow/panel/debug-and-preview/index.tsx b/web/app/components/workflow/panel/debug-and-preview/index.tsx
index a9777d21d92..3f861b794e7 100644
--- a/web/app/components/workflow/panel/debug-and-preview/index.tsx
+++ b/web/app/components/workflow/panel/debug-and-preview/index.tsx
@@ -4,9 +4,10 @@ import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { debounce } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
-import { memo, useCallback, useMemo, useRef, useState } from 'react'
+import { memo, useCallback, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
+import ResizeHandle from '@/app/components/base/resize-handle'
import { useStore } from '@/app/components/workflow/store'
import { useEdgesInteractionsWithoutSync } from '../../hooks/use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '../../hooks/use-nodes-interactions-without-sync'
@@ -14,6 +15,7 @@ import { useWorkflowInteractions } from '../../hooks/use-workflow-panel-interact
import { useResizePanel } from '../../nodes/_base/hooks/use-resize-panel'
import { useSetDebugPreviewPanelWidth } from '../../persistence/local-storage-options'
import { BlockEnum } from '../../types'
+import { getPreviewPanelMaxWidth } from '../panel-width'
import ChatWrapper from './chat-wrapper'
export type ChatWrapperRefType = {
@@ -21,6 +23,7 @@ export type ChatWrapperRefType = {
}
const DebugAndPreview = () => {
const { t } = useTranslation()
+ const panelId = useId()
const chatRef = useRef({ handleRestart: noop })
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync()
@@ -44,7 +47,6 @@ const DebugAndPreview = () => {
}
const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth)
- const nodePanelWidth = useStore((s) => s.nodePanelWidth)
const panelWidth = useStore((s) => s.previewPanelWidth)
const setPanelWidth = useStore((s) => s.setPreviewPanelWidth)
const setPanelWidthStorage = useSetDebugPreviewPanelWidth()
@@ -55,13 +57,7 @@ const DebugAndPreview = () => {
},
[setPanelWidth, setPanelWidthStorage],
)
- const maxPanelWidth = useMemo(() => {
- if (!workflowCanvasWidth) return 720
-
- if (!selectedNode) return workflowCanvasWidth - 400
-
- return workflowCanvasWidth - 400 - 400
- }, [workflowCanvasWidth, selectedNode, nodePanelWidth])
+ const maxPanelWidth = getPreviewPanelMaxWidth(workflowCanvasWidth, !!selectedNode, 720)
const { triggerRef, containerRef } = useResizePanel({
direction: 'horizontal',
triggerDirection: 'left',
@@ -74,13 +70,21 @@ const DebugAndPreview = () => {
return (
-
$['common.debugAndPreview'], { ns: 'workflow' })}
+ onResize={(width) => handleResize(width, 'user')}
+ className="absolute top-0 -left-1 flex h-full w-1 cursor-col-resize items-center justify-center"
>
-
-
+
+
import('@/app/components/workflow/panel/version-history-panel'),
@@ -102,19 +103,10 @@ const Panel: FC
= ({ components, versionHistoryPanelProps }) => {
const previewPanelWidth = useStore((s) => s.previewPanelWidth)
const setPreviewPanelWidth = useStore((s) => s.setPreviewPanelWidth)
- // When a node is selected and the NodePanel appears, if the current width
- // of preview/otherPanel is too large, it may result in the total width of
- // the two panels exceeding the workflowCanvasWidth, causing the NodePanel
- // to be pushed out. Here we check and, if necessary, reduce the previewPanelWidth
- // to "workflowCanvasWidth - 400 (minimum NodePanel width) - 400 (minimum canvas space)",
- // while still ensuring that previewPanelWidth ≥ 400.
-
useEffect(() => {
- if (!selectedNode || !workflowCanvasWidth) return
+ if (!workflowCanvasWidth) return
- const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas
- const minNodePanelWidth = 400
- const maxAllowed = Math.max(workflowCanvasWidth - reservedCanvasWidth - minNodePanelWidth, 400)
+ const maxAllowed = getPreviewPanelMaxWidth(workflowCanvasWidth, !!selectedNode)
if (previewPanelWidth > maxAllowed) setPreviewPanelWidth(maxAllowed)
}, [selectedNode, workflowCanvasWidth, previewPanelWidth, setPreviewPanelWidth])
diff --git a/web/app/components/workflow/panel/panel-width.ts b/web/app/components/workflow/panel/panel-width.ts
new file mode 100644
index 00000000000..4d8a56ba915
--- /dev/null
+++ b/web/app/components/workflow/panel/panel-width.ts
@@ -0,0 +1,11 @@
+export const getPreviewPanelMaxWidth = (
+ workflowCanvasWidth: number | undefined,
+ hasSelectedNode: boolean,
+ fallback = 1024,
+) => {
+ if (!workflowCanvasWidth) return fallback
+
+ // Keep the canvas visible and allow an open node panel to shrink to its minimum.
+ const reservedWidth = 400 + (hasSelectedNode ? 400 : 0)
+ return Math.max(400, workflowCanvasWidth - reservedWidth)
+}
diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx
index a8b478a6a6c..9a2f472a873 100644
--- a/web/app/components/workflow/panel/workflow-preview.tsx
+++ b/web/app/components/workflow/panel/workflow-preview.tsx
@@ -3,10 +3,12 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import copy from 'copy-to-clipboard'
-import { memo, useCallback, useEffect, useState } from 'react'
+import { memo, useCallback, useEffect, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { useStore as useReactFlowStore } from 'reactflow'
import ReasoningPanel from '@/app/components/base/chat/chat/answer/reasoning-panel'
import Loading from '@/app/components/base/loading'
+import ResizeHandle from '@/app/components/base/resize-handle'
import { submitHumanInputForm } from '@/service/workflow'
import { useWorkflowInteractions } from '../hooks/use-workflow-panel-interactions'
import ResultPanel from '../run/result-panel'
@@ -18,9 +20,11 @@ import { formatWorkflowRunIdentifier } from '../utils'
import HumanInputFilledFormList from './human-input-filled-form-list'
import HumanInputFormList from './human-input-form-list'
import InputsPanel from './inputs-panel'
+import { getPreviewPanelMaxWidth } from './panel-width'
const WorkflowPreview = () => {
const { t } = useTranslation()
+ const panelId = useId()
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
const workflowRunningData = useStore((s) => s.workflowRunningData)
const isListening = useStore((s) => s.isListening)
@@ -28,6 +32,8 @@ const WorkflowPreview = () => {
const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth)
const panelWidth = useStore((s) => s.previewPanelWidth)
const setPreviewPanelWidth = useStore((s) => s.setPreviewPanelWidth)
+ const hasSelectedNode = useReactFlowStore((s) => s.getNodes().some((node) => node.data.selected))
+ const maxPanelWidth = getPreviewPanelMaxWidth(workflowCanvasWidth, hasSelectedNode)
const showDebugAndPreviewPanel = useStore((s) => s.showDebugAndPreviewPanel)
const humanInputFormDataList = useStore((s) => s.workflowRunningData?.humanInputFormDataList)
const humanInputFilledFormDataList = useStore(
@@ -75,14 +81,10 @@ const WorkflowPreview = () => {
(e: MouseEvent) => {
if (isResizing) {
const newWidth = window.innerWidth - e.clientX
- // width constraints: 400 <= width <= maxAllowed (canvas - reserved 400)
- const reservedCanvasWidth = 400
- const maxAllowed = workflowCanvasWidth ? workflowCanvasWidth - reservedCanvasWidth : 1024
-
- if (newWidth >= 400 && newWidth <= maxAllowed) setPreviewPanelWidth(newWidth)
+ if (newWidth >= 400 && newWidth <= maxPanelWidth) setPreviewPanelWidth(newWidth)
}
},
- [isResizing, workflowCanvasWidth, setPreviewPanelWidth],
+ [isResizing, maxPanelWidth, setPreviewPanelWidth],
)
useEffect(() => {
@@ -107,11 +109,19 @@ const WorkflowPreview = () => {
return (
-
$['singleRun.testRun'], { ns: 'workflow' })}
+ onResize={setPreviewPanelWidth}
+ className="absolute top-1/2 bottom-0 left-0.75 z-50 h-6 w-0.75 cursor-col-resize bg-state-base-handle"
onMouseDown={startResizing}
/>
diff --git a/web/app/components/workflow/variable-inspect/__tests__/index.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/index.spec.tsx
index 7fb605c2d5e..337cb3badce 100644
--- a/web/app/components/workflow/variable-inspect/__tests__/index.spec.tsx
+++ b/web/app/components/workflow/variable-inspect/__tests__/index.spec.tsx
@@ -1,7 +1,65 @@
+import { act, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import VariableInspectPanel from '../index'
+vi.mock('../panel', () => ({ default: () =>
Variables
}))
+
describe('variable inspect index', () => {
+ it('grows upward with the keyboard and persists its height', async () => {
+ const user = userEvent.setup()
+ renderWorkflowComponent(
, {
+ initialStoreState: {
+ showVariableInspectPanel: true,
+ variableInspectPanelHeight: 200,
+ workflowCanvasHeight: 500,
+ },
+ })
+ await user.tab()
+ const handle = screen.getByRole('separator', { name: 'workflow.debug.variableInspect.title' })
+ expect(handle).toHaveFocus()
+ await user.keyboard('{ArrowUp}{Shift>}{ArrowUp}{/Shift}')
+ expect(handle).toHaveAttribute('aria-valuenow', '240')
+ await waitFor(() =>
+ expect(localStorage.getItem('workflow-variable-inpsect-panel-height')).toBe('240'),
+ )
+ await user.keyboard('{End}{ArrowUp}')
+ expect(handle).toHaveAttribute('aria-valuenow', '440')
+ await user.keyboard('{Home}{ArrowDown}')
+ expect(handle).toHaveAttribute('aria-valuenow', '120')
+ })
+
+ it('constrains its height after the canvas shrinks without replacing the saved preference', async () => {
+ const user = userEvent.setup()
+ const { store } = renderWorkflowComponent(
, {
+ initialStoreState: {
+ showVariableInspectPanel: true,
+ variableInspectPanelHeight: 200,
+ workflowCanvasHeight: 500,
+ },
+ })
+ await user.tab()
+ const handle = screen.getByRole('separator', { name: 'workflow.debug.variableInspect.title' })
+ await user.keyboard('{End}')
+ expect(handle).toHaveAttribute('aria-valuenow', '440')
+ await waitFor(() =>
+ expect(localStorage.getItem('workflow-variable-inpsect-panel-height')).toBe('440'),
+ )
+
+ act(() => store.getState().setWorkflowCanvasHeight(300))
+
+ await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '240'))
+ expect(handle).toHaveAttribute('aria-valuemax', '240')
+ const panel = document.getElementById(handle.getAttribute('aria-controls')!)!
+ expect(panel).toHaveStyle({ height: '240px' })
+ expect(localStorage.getItem('workflow-variable-inpsect-panel-height')).toBe('440')
+ expect(handle).toHaveFocus()
+ await user.keyboard('{ArrowUp}')
+ expect(handle).toHaveAttribute('aria-valuenow', '240')
+ await user.keyboard('{ArrowDown}')
+ expect(handle).toHaveAttribute('aria-valuenow', '232')
+ })
+
it('renders nothing when the inspect panel is hidden', () => {
const { container } = renderWorkflowComponent(
, {
initialStoreState: {
diff --git a/web/app/components/workflow/variable-inspect/index.tsx b/web/app/components/workflow/variable-inspect/index.tsx
index aac454eab83..775184f560d 100644
--- a/web/app/components/workflow/variable-inspect/index.tsx
+++ b/web/app/components/workflow/variable-inspect/index.tsx
@@ -1,13 +1,17 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { debounce } from 'es-toolkit/compat'
-import { useCallback, useMemo } from 'react'
+import { useCallback, useEffect, useId, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import ResizeHandle from '@/app/components/base/resize-handle'
import { useResizePanel } from '../nodes/_base/hooks/use-resize-panel'
import { useSetWorkflowVariableInspectPanelHeight } from '../persistence/local-storage-options'
import { useStore } from '../store'
import Panel from './panel'
const VariableInspectPanel: FC = () => {
+ const { t } = useTranslation('workflow')
+ const panelId = useId()
const showVariableInspectPanel = useStore((s) => s.showVariableInspectPanel)
const workflowCanvasHeight = useStore((s) => s.workflowCanvasHeight)
const variableInspectPanelHeight = useStore((s) => s.variableInspectPanelHeight)
@@ -15,9 +19,14 @@ const VariableInspectPanel: FC = () => {
const maxHeight = useMemo(() => {
if (!workflowCanvasHeight) return 480
- return workflowCanvasHeight - 60
+ return Math.max(120, workflowCanvasHeight - 60)
}, [workflowCanvasHeight])
+ useEffect(() => {
+ if (!workflowCanvasHeight) return
+ if (variableInspectPanelHeight > maxHeight) setVariableInspectPanelHeight(maxHeight)
+ }, [workflowCanvasHeight, variableInspectPanelHeight, maxHeight, setVariableInspectPanelHeight])
+
const setPanelHeightStorage = useSetWorkflowVariableInspectPanelHeight()
const handleResize = useCallback(
@@ -40,13 +49,21 @@ const VariableInspectPanel: FC = () => {
return (
-
$['debug.variableInspect.title'])}
+ onResize={(height) => handleResize(0, height)}
+ className="absolute -top-1 left-0 flex h-1 w-full cursor-row-resize items-center justify-center"
>
-
-
+
+
{
})
it('resizes the file tree sidebar within its accessible range', async () => {
+ const user = userEvent.setup()
renderSkillDetailPage()
const resizeHandle = await screen.findByRole('separator', {
@@ -120,11 +121,14 @@ describe('SkillDetailPage navigation', () => {
expect(resizeHandle).toHaveAttribute('aria-valuenow', '240')
fireEvent.pointerUp(document)
- fireEvent.keyDown(resizeHandle, { key: 'ArrowRight' })
+ resizeHandle.focus()
+ await user.keyboard('{ArrowRight}')
expect(resizeHandle).toHaveAttribute('aria-valuenow', '248')
- fireEvent.keyDown(resizeHandle, { key: 'End' })
+ await user.keyboard('{Shift>}{ArrowRight}{/Shift}')
+ expect(resizeHandle).toHaveAttribute('aria-valuenow', '280')
+ await user.keyboard('{End}')
expect(resizeHandle).toHaveAttribute('aria-valuenow', '420')
- fireEvent.keyDown(resizeHandle, { key: 'Home' })
+ await user.keyboard('{Home}')
expect(resizeHandle).toHaveAttribute('aria-valuenow', '240')
})
diff --git a/web/features/skills/detail/file-tree.tsx b/web/features/skills/detail/file-tree.tsx
index 80a4ab271c9..7c546f15e7a 100644
--- a/web/features/skills/detail/file-tree.tsx
+++ b/web/features/skills/detail/file-tree.tsx
@@ -58,6 +58,7 @@ import copy from 'copy-to-clipboard'
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
+import { getKeyboardResizeValue } from '@/app/components/base/resize-handle/keyboard'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import AccountSection from '@/app/components/main-nav/components/account-section'
@@ -107,7 +108,6 @@ import {
const skillSidebarMinWidth = 240
const skillSidebarMaxWidth = 420
-const skillSidebarKeyboardStep = 8
const skillSidebarHelpTriggerIcon = (
@@ -176,6 +176,7 @@ export function FileTree({
const queryClient = useQueryClient()
const sidebarRef = useRef(null)
const filesTitleId = useId()
+ const sidebarPanelId = useId()
const uploadInputRef = useRef(null)
const [inlineAction, setInlineAction] = useState()
const [draggingPaths, setDraggingPaths] = useState([])
@@ -288,15 +289,17 @@ export function FileTree({
)
const handleSidebarResizeKeyDown = (event: ReactKeyboardEvent) => {
- let nextWidth: number | undefined
- if (event.key === 'ArrowLeft') nextWidth = sidebarWidth - skillSidebarKeyboardStep
- if (event.key === 'ArrowRight') nextWidth = sidebarWidth + skillSidebarKeyboardStep
- if (event.key === 'Home') nextWidth = skillSidebarMinWidth
- if (event.key === 'End') nextWidth = skillSidebarMaxWidth
+ const nextWidth = getKeyboardResizeValue(event, {
+ side: 'right',
+ value: sidebarWidth,
+ min: skillSidebarMinWidth,
+ max: skillSidebarMaxWidth,
+ })
if (nextWidth === undefined) return
event.preventDefault()
- setSidebarWidth(clampSkillSidebarWidth(nextWidth))
+ event.stopPropagation()
+ setSidebarWidth(nextWidth)
}
const fileMutation = useMutation(
@@ -1157,6 +1160,7 @@ export function FileTree({
onMouseLeave={collapsed ? closeSidebarFloatingPreview : undefined}
>