fix: make panel and node resizing keyboard accessible (#41963)

Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Joel 2026-09-08 07:44:41 +00:00 committed by GitHub
parent 9e0763e189
commit a233b2f53a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 1472 additions and 91 deletions

View File

@ -221,11 +221,6 @@
"count": 2
}
},
"web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx": {
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx": {
"typescript/no-explicit-any": {
"count": 3
@ -2947,7 +2942,7 @@
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 6
"count": 5
}
},
"web/app/components/snippets/hooks/use-snippet-run.ts": {
@ -4438,7 +4433,7 @@
"count": 4
},
"jsx-a11y/no-static-element-interactions": {
"count": 5
"count": 4
},
"typescript/no-explicit-any": {
"count": 1

View File

@ -0,0 +1,69 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import PromptEditorHeightResizeWrap from '../prompt-editor-height-resize-wrap'
function Editor({ hideResize = false }: { hideResize?: boolean }) {
const [height, setHeight] = useState(200)
return (
<PromptEditorHeightResizeWrap
height={height}
minHeight={120}
onHeightChange={setHeight}
hideResize={hideResize}
>
<textarea aria-label="Prompt" />
</PromptEditorHeightResizeWrap>
)
}
describe('PromptEditorHeightResizeWrap', () => {
it('supports keyboard height changes without intercepting editor keys or imposing a maximum', async () => {
const user = userEvent.setup()
render(<Editor />)
const handle = screen.getByRole('button', { name: 'common.resize.editor' })
const editor = document.getElementById(handle.getAttribute('aria-controls')!)!
await user.tab()
await user.keyboard('{ArrowDown}')
expect(editor).toHaveStyle({ height: '200px' })
await user.tab()
expect(handle).toHaveFocus()
await user.keyboard('{ArrowDown}{Shift>}{ArrowDown}{/Shift}')
expect(editor).toHaveStyle({ height: '240px' })
await user.keyboard('{ArrowUp}{End}')
expect(editor).toHaveStyle({ height: '232px' })
await user.keyboard('{Home}{ArrowUp}')
expect(editor).toHaveStyle({ height: '120px' })
await user.keyboard('{ArrowDown}{Enter}')
expect(editor).toHaveStyle({ height: '120px' })
await user.tab({ shift: true })
expect(screen.getByRole('textbox', { name: 'Prompt' })).toHaveFocus()
})
it.each([true, false])(
'continues resizing by keyboard after a mouse drag (click on release: %s)',
async (clickOnRelease) => {
const user = userEvent.setup()
render(<Editor />)
const handle = screen.getByRole('button', { name: 'common.resize.editor' })
const editor = document.getElementById(handle.getAttribute('aria-controls')!)!
fireEvent.mouseDown(handle, { clientY: 200 })
fireEvent.mouseMove(document, { clientY: 260 })
await waitFor(() => expect(editor).toHaveStyle({ height: '260px' }))
fireEvent.mouseUp(document)
if (clickOnRelease) fireEvent.click(handle, { detail: 1 })
expect(editor).toHaveStyle({ height: '260px' })
await user.tab()
await user.tab()
await user.keyboard('{ArrowDown}')
expect(editor).toHaveStyle({ height: '268px' })
await user.keyboard('{Enter}')
expect(editor).toHaveStyle({ height: '120px' })
},
)
it('does not expose a resize control when resizing is hidden', () => {
render(<Editor hideResize />)
expect(screen.queryByRole('button', { name: 'common.resize.editor' })).not.toBeInTheDocument()
})
})

View File

@ -1,9 +1,12 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getKeyboardResizeValue } from '@/app/components/base/resize-handle/keyboard'
type Props = Readonly<{
className?: string
@ -24,6 +27,10 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
footer,
hideResize,
}) => {
const { t } = useTranslation('common')
const editorId = useId()
const resizeDescriptionId = useId()
const didDragRef = useRef(false)
const [clientY, setClientY] = useState(0)
const [isResizing, setIsResizing] = useState(false)
const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(
@ -33,6 +40,7 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
const handleStartResize = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
didDragRef.current = false
setClientY(e.clientY)
setIsResizing(true)
setOldHeight(height)
@ -61,7 +69,13 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
},
)
const handleResize = useCallback(didHandleResize, [isResizing, height, minHeight, clientY])
const handleResize = useCallback(
(event: MouseEvent) => {
if (isResizing && event.clientY !== clientY) didDragRef.current = true
didHandleResize(event)
},
[didHandleResize, isResizing, clientY],
)
useEffect(() => {
document.addEventListener('mousemove', handleResize)
@ -80,6 +94,7 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
return (
<div className="relative">
<div
id={editorId}
className={cn(className, 'overflow-y-auto')}
style={{
height,
@ -90,12 +105,40 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
{/* resize handler */}
{footer}
{!hideResize && (
<div
className="absolute bottom-0 left-0 flex h-2 w-full cursor-row-resize justify-center"
onMouseDown={handleStartResize}
>
<div className="h-0.75 w-5 rounded-xs bg-gray-300"></div>
</div>
<>
<IconButton
aria-label={t(($) => $['resize.editor'])}
aria-controls={editorId}
aria-describedby={resizeDescriptionId}
className="group/resize absolute bottom-0 left-0 h-2 w-full cursor-row-resize"
onMouseDown={handleStartResize}
onClick={(event) => {
if (event.detail === 0 || !didDragRef.current) onHeightChange(minHeight)
didDragRef.current = false
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
const next = getKeyboardResizeValue(event, {
side: 'bottom',
value: height,
min: minHeight,
})
if (next === undefined) return
event.preventDefault()
event.stopPropagation()
onHeightChange(next)
}}
>
<span
aria-hidden="true"
className="h-0.75 w-5 rounded-xs bg-state-base-handle group-focus-visible/resize:w-full group-focus-visible/resize:bg-state-accent-solid"
/>
</IconButton>
<span id={resizeDescriptionId} className="sr-only" aria-live="polite">
{t(($) => $['resize.height'], { height: Math.round(height) })}{' '}
{t(($) => $['resize.editorHelp'])}
</span>
</>
)}
</div>
)

View File

@ -0,0 +1,89 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import ResizeHandle from '..'
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
const { default: common } = await import('@/i18n/en-US/common.json')
return createReactI18nextMock(common)
})
function Panel({ side }: Pick<ComponentProps<typeof ResizeHandle>, 'side'>) {
const [value, setValue] = useState(480)
return (
<>
<ResizeHandle
side={side}
value={value}
min={400}
max={600}
controls="panel"
label="Panel size"
onResize={setValue}
/>
<div id="panel">{value}</div>
<button>Next control</button>
</>
)
}
describe('ResizeHandle', () => {
it.each([
['left', 'ArrowLeft', 'ArrowRight', 'vertical'],
['right', 'ArrowRight', 'ArrowLeft', 'vertical'],
['top', 'ArrowUp', 'ArrowDown', 'horizontal'],
['bottom', 'ArrowDown', 'ArrowUp', 'horizontal'],
] as const)(
'resizes from the %s edge and exposes the current size',
async (side, increase, decrease, orientation) => {
const user = userEvent.setup()
render(<Panel side={side} />)
await user.tab()
const handle = screen.getByRole('separator', { name: 'Panel size' })
expect(handle).toHaveFocus()
expect(handle).toHaveAttribute('aria-orientation', orientation)
await user.keyboard(`{${increase}}{Shift>}{${increase}}{/Shift}`)
expect(handle).toHaveAttribute('aria-valuenow', '520')
expect(handle).toHaveAttribute(
'aria-valuetext',
`${orientation === 'vertical' ? 'Width' : 'Height'}: 520 pixels`,
)
await user.keyboard(`{${decrease}}`)
expect(handle).toHaveAttribute('aria-valuenow', '512')
await user.keyboard(`{Home}{${decrease}}`)
expect(handle).toHaveAttribute('aria-valuenow', '400')
await user.keyboard(`{End}{${increase}}`)
expect(handle).toHaveAttribute('aria-valuenow', '600')
await user.tab()
expect(screen.getByRole('button', { name: 'Next control' })).toHaveFocus()
await user.tab({ shift: true })
expect(handle).toHaveFocus()
},
)
it('does not consume unrelated shortcuts and retains the mouse handler', async () => {
const user = userEvent.setup()
const onResize = vi.fn()
const onMouseDown = vi.fn()
render(
<ResizeHandle
side="left"
value={480}
min={400}
max={600}
controls="panel"
label="Panel size"
onResize={onResize}
onMouseDown={onMouseDown}
/>,
)
const handle = screen.getByRole('separator', { name: 'Panel size' })
await user.tab()
await user.keyboard('{ArrowDown}{Control>}{ArrowRight}{/Control}{Alt>}{ArrowLeft}{/Alt}')
expect(onResize).not.toHaveBeenCalled()
fireEvent.mouseDown(handle)
expect(onMouseDown).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,64 @@
'use client'
import type { ComponentProps } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { getKeyboardResizeValue } from './keyboard'
type ResizeHandleProps = Pick<
ComponentProps<'div'>,
'ref' | 'className' | 'children' | 'onMouseDown' | 'onPointerDown'
> & {
side: 'left' | 'right' | 'top' | 'bottom'
value: number
min: number
max: number
label: string
controls: string
onResize: (value: number) => void
}
export default function ResizeHandle({
side,
value,
min,
max,
label,
controls,
onResize,
className,
...props
}: ResizeHandleProps) {
const { t } = useTranslation('common')
const horizontal = side === 'left' || side === 'right'
return (
<div
{...props}
role="separator"
tabIndex={0}
aria-label={label}
aria-controls={controls}
aria-orientation={horizontal ? 'vertical' : 'horizontal'}
aria-valuemin={min}
aria-valuemax={Math.max(min, max)}
aria-valuenow={value}
aria-valuetext={
horizontal
? t(($) => $['resize.width'], { width: Math.round(value) })
: t(($) => $['resize.height'], { height: Math.round(value) })
}
onKeyDown={(event) => {
const next = getKeyboardResizeValue(event, { side, value, min, max })
if (next === undefined) return
event.preventDefault()
event.stopPropagation()
onResize(next)
}}
className={cn(
'group/resize rounded-sm focus-visible:bg-state-accent-solid focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
className,
)}
/>
)
}

View File

@ -0,0 +1,30 @@
import type { KeyboardEvent } from 'react'
type ResizeOptions = {
side: 'left' | 'right' | 'top' | 'bottom'
value: number
min: number
max?: number
}
export function getKeyboardResizeValue(
event: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey'>,
{ side, value, min, max = Infinity }: ResizeOptions,
) {
if (event.altKey || event.ctrlKey || event.metaKey) return undefined
const step = event.shiftKey ? 32 : 8
const horizontal = side === 'left' || side === 'right'
const backward = horizontal ? 'ArrowLeft' : 'ArrowUp'
const forward = horizontal ? 'ArrowRight' : 'ArrowDown'
const sign = side === 'left' || side === 'top' ? -1 : 1
const upperBound = Math.max(min, max)
let next: number
if (event.key === backward) next = value - step * sign
else if (event.key === forward) next = value + step * sign
else if (event.key === 'Home') next = min
else if (event.key === 'End' && Number.isFinite(upperBound)) next = upperBound
else return undefined
return Math.max(min, Math.min(next, upperBound))
}

View File

@ -1,11 +1,17 @@
import type { SnippetInputField } from '@/models/snippet'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { ReactFlowProvider } from 'reactflow'
import { renderWorkflowComponent as renderWithWorkflowStore } from '@/app/components/workflow/__tests__/workflow-test-env'
import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetRunPanel from '../snippet-run-panel'
const renderWorkflowComponent = (
ui: Parameters<typeof renderWithWorkflowStore>[0],
options?: Parameters<typeof renderWithWorkflowStore>[1],
) => renderWithWorkflowStore(<ReactFlowProvider>{ui}</ReactFlowProvider>, options)
const workflowHookMocks = vi.hoisted(() => ({
handleCancelDebugAndPreviewPanel: vi.fn(),
handleRun: vi.fn(),
@ -98,6 +104,22 @@ describe('SnippetRunPanel', () => {
checkInputMocks.checkInputsForm.mockReturnValue(true)
})
it('resizes the run panel with the keyboard within the available canvas width', async () => {
const user = userEvent.setup()
renderWorkflowComponent(<SnippetRunPanel fields={[]} />, {
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 render snippet input fields with defaults and run with edited inputs', async () => {
const user = userEvent.setup()

View File

@ -6,14 +6,17 @@ import type { SnippetInputField } from '@/models/snippet'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import copy from 'copy-to-clipboard'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore } from 'reactflow'
import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks'
import { getProcessedInputs } from '@/app/components/base/chat/chat/utils'
import Loading from '@/app/components/base/loading'
import ResizeHandle from '@/app/components/base/resize-handle'
import { useWorkflowInteractions } from '@/app/components/workflow/hooks/use-workflow-panel-interactions'
import { useWorkflowRun } from '@/app/components/workflow/hooks/use-workflow-run'
import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item'
import { getPreviewPanelMaxWidth } from '@/app/components/workflow/panel/panel-width'
import ResultPanel from '@/app/components/workflow/run/result-panel'
import ResultText from '@/app/components/workflow/run/result-text'
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
@ -66,6 +69,7 @@ const buildInitialInputs = (fields: SnippetRunField[]) => {
const SnippetRunPanel = ({ fields }: SnippetRunPanelProps) => {
const { t } = useTranslation()
const panelId = useId()
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
const { handleRun } = useWorkflowRun()
const { checkInputsForm } = useCheckInputsForms()
@ -74,6 +78,8 @@ const SnippetRunPanel = ({ fields }: SnippetRunPanelProps) => {
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 previewFields = useMemo(() => buildPreviewFields(fields), [fields])
const initialInputs = useMemo(() => buildInitialInputs(previewFields), [previewFields])
@ -125,12 +131,9 @@ const SnippetRunPanel = ({ fields }: SnippetRunPanelProps) => {
if (!isResizing) return
const newWidth = window.innerWidth - e.clientX
const reservedCanvasWidth = 400
const maxAllowed = workflowCanvasWidth ? workflowCanvasWidth - reservedCanvasWidth : 1024
if (newWidth >= 400 && newWidth <= maxAllowed) setPreviewPanelWidth(newWidth)
if (newWidth >= 400 && newWidth <= maxPanelWidth) setPreviewPanelWidth(newWidth)
},
[isResizing, setPreviewPanelWidth, workflowCanvasWidth],
[isResizing, setPreviewPanelWidth, maxPanelWidth],
)
useEffect(() => {
@ -144,11 +147,19 @@ const SnippetRunPanel = ({ fields }: SnippetRunPanelProps) => {
return (
<div
id={panelId}
className="relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
style={{ width: `${panelWidth}px` }}
>
<div
className="absolute top-1/2 bottom-0 left-0.75 z-50 h-6 w-0.75 cursor-col-resize rounded bg-gray-300"
<ResizeHandle
side="left"
value={panelWidth}
min={400}
max={maxPanelWidth}
controls={panelId}
label={t(($) => $['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}
/>
<div className="flex items-center justify-between p-4 pb-1 text-base font-semibold text-text-primary">

View File

@ -0,0 +1,187 @@
import type { ReactNode } from 'react'
import type { NodeProps, ResizeParamsWithDirection } from 'reactflow'
import type { CommonNodeType } from '@/app/components/workflow/types'
import ReactFlow from 'reactflow'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { WorkflowContext } from '@/app/components/workflow/context'
import NoteNode from '@/app/components/workflow/note-node'
import { NoteTheme } from '@/app/components/workflow/note-node/types'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import NodeResizer from '../node-resizer'
import 'reactflow/dist/style.css'
vi.mock('../../../../hooks/use-workflow', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
}))
// Editing and persistence are independent of the Note's rendered resize affordance.
vi.mock('../../../../note-node/note-editor', () => ({
NoteEditorContextProvider: ({ children }: { children: ReactNode }) => children,
NoteEditor: () => <span>Note content</span>,
NoteEditorToolbar: () => null,
}))
vi.mock('../../../../note-node/hooks', () => ({
useNote: () => ({
handleThemeChange: vi.fn(),
handleEditorChange: vi.fn(),
handleShowAuthorChange: vi.fn(),
}),
}))
vi.mock('../../../../hooks/use-node-data-update', () => ({
useNodeDataUpdate: () => ({ handleNodeDataUpdateWithSyncDraft: vi.fn() }),
}))
vi.mock('../../../../hooks/use-nodes-interactions', async () => {
const { useStoreApi } = await import('reactflow')
return {
useNodesInteractions: () => {
const store = useStoreApi()
return {
handleNodeResize: (id: string, { width, height }: ResizeParamsWithDirection) => {
const { getNodes, setNodes } = store.getState()
setNodes(
getNodes().map((node) =>
node.id === id
? {
...node,
width,
height,
data: { ...node.data, width, height },
}
: node,
),
)
},
}
},
}
})
function ResizableNode({ id, data }: NodeProps<CommonNodeType>) {
return (
<div
role="group"
aria-label="Resizable node"
className="group relative rounded-lg bg-components-panel-bg"
style={{ width: data.width, height: data.height }}
>
<NodeResizer nodeId={id} nodeData={data} />
</div>
)
}
const nodeTypes = { resize: ResizableNode }
const noteNodeTypes = { note: NoteNode }
const nodes = [
{
id: 'node',
type: 'resize',
position: { x: 100, y: 100 },
data: { title: 'Note', desc: '', type: BlockEnum.Iteration, width: 300, height: 200 },
},
]
it('hides an unselected Note resize handle from pointer targeting until hover or keyboard focus', async () => {
// Browser-owned: transparent descendants still participate in CSS hit testing,
// while native Tab focus must reveal the real Note's resize affordance.
await page.viewport(1000, 800)
const store = createWorkflowStore({})
const screen = await render(
<WorkflowContext value={store}>
<button type="button">Before canvas</button>
<div role="group" aria-label="Canvas" style={{ width: 800, height: 600 }}>
<ReactFlow
defaultNodes={[
{
id: 'note',
type: 'note',
ariaLabel: 'Note',
position: { x: 100, y: 100 },
data: {
title: 'Note',
desc: '',
type: BlockEnum.Code,
text: '',
theme: NoteTheme.blue,
author: 'Alice',
showAuthor: false,
selected: false,
width: 300,
height: 200,
},
},
]}
nodeTypes={noteNodeTypes}
/>
</div>
</WorkflowContext>,
)
const node = screen.getByRole('button', { name: 'Note', exact: true })
const control = screen.getByRole('button', { name: 'common.resize.node' })
const beforeCanvas = screen.getByRole('button', { name: 'Before canvas' })
await expect.element(node).toBeVisible()
await beforeCanvas.click()
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(false)
const rect = control.element().getBoundingClientRect()
const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)
expect(control.element().contains(hit)).toBe(false)
await node.hover()
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(true)
const hoveredHit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)
expect(control.element().contains(hoveredHit)).toBe(true)
expect(node.element().getBoundingClientRect().width).toBe(300)
expect(node.element().getBoundingClientRect().height).toBe(200)
await beforeCanvas.click()
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(false)
await userEvent.tab()
await userEvent.tab()
await expect.element(control).toHaveFocus()
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard('{ArrowRight}{ArrowDown}')
await expect.poll(() => node.element().getBoundingClientRect().width).toBe(308)
expect(node.element().getBoundingClientRect().height).toBe(208)
})
it('reveals the resize handle on keyboard focus and preserves mouse dragging', async () => {
// Browser-owned: opacity on ancestors, native Tab navigation, focus rings and D3 drag hit testing.
await page.viewport(1000, 800)
const screen = await render(
<>
<button type="button">Before canvas</button>
<div role="group" aria-label="Canvas" style={{ width: 800, height: 600 }}>
<ReactFlow defaultNodes={nodes} nodeTypes={nodeTypes} />
</div>
</>,
)
const node = screen.getByRole('group', { name: 'Resizable node', exact: true })
const control = screen.getByRole('button', { name: 'common.resize.node' })
await expect.element(node).toBeVisible()
await screen.getByRole('button', { name: 'Before canvas' }).click()
await userEvent.tab()
await userEvent.tab()
await expect.element(control).toHaveFocus()
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(true)
const focusStyle = getComputedStyle(control.element())
expect(focusStyle.boxShadow !== 'none' || focusStyle.outlineStyle !== 'none').toBe(true)
const original = node.element().getBoundingClientRect()
await userEvent.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
await expect.poll(() => node.element().getBoundingClientRect().width).toBe(308)
expect(node.element().getBoundingClientRect().height).toBe(232)
expect(node.element().getBoundingClientRect().left).toBe(original.left)
expect(node.element().getBoundingClientRect().top).toBe(original.top)
await userEvent.dragAndDrop(control, screen.getByRole('group', { name: 'Canvas', exact: true }), {
targetPosition: { x: 550, y: 450 },
})
await expect.poll(() => node.element().getBoundingClientRect().width).toBeGreaterThan(308)
expect(node.element().getBoundingClientRect().height).toBeGreaterThan(232)
await userEvent.keyboard('{Enter}')
await expect.poll(() => node.element().getBoundingClientRect().width).toBe(258)
expect(node.element().getBoundingClientRect().height).toBe(152)
})

View File

@ -0,0 +1,129 @@
import type { ResizeParamsWithDirection } from 'reactflow'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useNodes } from 'reactflow'
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum } from '@/app/components/workflow/types'
import NodeResizer from '../node-resizer'
const resize = vi.hoisted(() => vi.fn())
const permissions = vi.hoisted(() => ({ readonly: false }))
// Graph mutation owns draft persistence and history; keep React Flow and the resize UI real.
vi.mock('../../../../hooks/use-nodes-interactions', async () => {
const { useStoreApi } = await import('reactflow')
return {
useNodesInteractions: () => {
const store = useStoreApi()
return {
handleNodeResize: (id: string, params: ResizeParamsWithDirection) => {
resize(id, params)
store.getState().setNodes(
store
.getState()
.getNodes()
.map((node) =>
node.id === id
? {
...node,
width: params.width,
height: params.height,
data: { ...node.data, width: params.width, height: params.height },
}
: node,
),
)
},
}
},
}
})
vi.mock('../../../../hooks/use-workflow', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: permissions.readonly }),
}))
function ResizableNode() {
const node = useNodes<CommonNodeType>().find((node) => node.id === 'container')!
if (!node) return null
return (
<>
<NodeResizer nodeId={node.id} nodeData={node.data} />
<output aria-label="Node size">
{node.data.width} × {node.data.height}
</output>
</>
)
}
describe('NodeResizer', () => {
beforeEach(() => {
vi.clearAllMocks()
permissions.readonly = false
})
const container = () =>
createNode({
id: 'container',
position: { x: 50, y: 70 },
width: 500,
height: 400,
data: {
type: BlockEnum.Iteration,
title: 'Iteration',
desc: '',
width: 500,
height: 400,
selected: true,
_children: [{ nodeId: 'child', nodeType: BlockEnum.Code }],
},
})
it('resizes both dimensions without moving the node and shrinks only as far as its contents allow', async () => {
const user = userEvent.setup()
renderWorkflowFlowComponent(<ResizableNode />, {
nodes: [
container(),
createNode({
id: 'child',
parentId: 'container',
position: { x: 100, y: 100 },
width: 240,
height: 100,
}),
],
})
const control = await screen.findByRole('button', { name: 'common.resize.node' })
control.focus()
await user.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('508 × 432')
expect(resize).toHaveBeenLastCalledWith(
'container',
expect.objectContaining({ x: 50, y: 70, width: 508, height: 432 }),
)
await user.keyboard('{Home}')
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('356 × 220')
await user.keyboard('{ArrowLeft}{ArrowUp}')
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('356 × 220')
await user.keyboard('{ArrowRight}{ArrowDown}{Enter}')
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('356 × 220')
await user.keyboard('{End}')
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('356 × 220')
})
it('keeps the resize action available when the node is not selected', async () => {
const node = container()
node.data.selected = false
renderWorkflowFlowComponent(<ResizableNode />, { nodes: [node] })
expect(await screen.findByRole('button', { name: 'common.resize.node' })).toBeEnabled()
})
it('does not offer resizing on a readonly canvas', async () => {
permissions.readonly = true
renderWorkflowFlowComponent(<ResizableNode />, { nodes: [container()] })
await screen.findByRole('status', { name: 'Node size' })
expect(screen.queryByRole('button', { name: 'common.resize.node' })).not.toBeInTheDocument()
})
})

View File

@ -1,9 +1,15 @@
import type { OnResize } from 'reactflow'
import type { CommonNodeType } from '../../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { memo, useCallback } from 'react'
import { NodeResizeControl } from 'reactflow'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { memo, useCallback, useId, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { NodeResizeControl, useReactFlow } from 'reactflow'
import { getKeyboardResizeValue } from '@/app/components/base/resize-handle/keyboard'
import { ITERATION_PADDING, LOOP_PADDING } from '../../../constants'
import { useNodesInteractions } from '../../../hooks/use-nodes-interactions'
import { useNodesReadOnly } from '../../../hooks/use-workflow'
import { BlockEnum } from '../../../types'
const Icon = () => {
return (
@ -35,27 +41,124 @@ const NodeResizer = ({
minHeight = 152,
maxWidth,
}: NodeResizerProps) => {
const { t } = useTranslation('common')
const descriptionId = useId()
const didDragRef = useRef(false)
const { getNode, getNodes } = useReactFlow<CommonNodeType>()
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeResize } = useNodesInteractions()
const handleResize = useCallback<OnResize>(
(_, params) => {
didDragRef.current = true
handleNodeResize(nodeId, params)
},
[nodeId, handleNodeResize],
)
const resizeWithKeyboard = (
event: Pick<React.KeyboardEvent, 'key' | 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey'>,
) => {
if (nodesReadOnly) return false
const node = getNode(nodeId)
if (!node) return false
const padding = node.data.type === BlockEnum.Iteration ? ITERATION_PADDING : LOOP_PADDING
const children = getNodes().filter((child) =>
node.data._children?.some(({ nodeId }) => nodeId === child.id),
)
const requiredWidth = Math.max(
minWidth,
...children.map(
(child) => child.position.x + (child.width ?? child.data.width ?? 0) + padding.right,
),
)
const requiredHeight = Math.max(
minHeight,
...children.map(
(child) => child.position.y + (child.height ?? child.data.height ?? 0) + padding.bottom,
),
)
const width = node.data.width ?? node.width ?? minWidth
const height = node.data.height ?? node.height ?? minHeight
const nextWidth = getKeyboardResizeValue(event, {
side: 'right',
value: width,
min: requiredWidth,
max: maxWidth,
})
const nextHeight = getKeyboardResizeValue(event, {
side: 'bottom',
value: height,
min: requiredHeight,
})
if (nextWidth === undefined && nextHeight === undefined) return false
handleNodeResize(nodeId, {
...node.position,
width: nextWidth ?? width,
height: nextHeight ?? height,
direction: [nextWidth === undefined ? 0 : 1, nextHeight === undefined ? 0 : 1],
})
return true
}
if (nodesReadOnly) return null
return (
<div className={cn('hidden group-hover:block', nodeData.selected && 'block!')}>
<div
className={cn(
'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
nodeData.selected && 'pointer-events-auto opacity-100',
)}
>
<NodeResizeControl
nodeId={nodeId}
position="bottom-right"
className="border-none! bg-transparent!"
onResize={handleResize}
onResizeStart={() => {
didDragRef.current = false
}}
minWidth={minWidth}
minHeight={minHeight}
maxWidth={maxWidth}
>
<div className="absolute right-px bottom-px">{icon}</div>
<IconButton
aria-label={t(($) => $['resize.node'])}
aria-describedby={descriptionId}
className="nodrag nopan absolute right-px bottom-px"
onClick={(event) => {
event.stopPropagation()
if (event.detail > 0 && didDragRef.current) {
didDragRef.current = false
return
}
didDragRef.current = false
resizeWithKeyboard({
key: 'Home',
shiftKey: false,
altKey: false,
ctrlKey: false,
metaKey: false,
})
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
if (!resizeWithKeyboard(event)) return
event.preventDefault()
event.stopPropagation()
}}
>
<span aria-hidden="true">{icon}</span>
</IconButton>
</NodeResizeControl>
<span id={descriptionId} className="sr-only" aria-live="polite">
{t(($) => $['resize.size'], {
width: Math.round(nodeData.width ?? minWidth),
height: Math.round(nodeData.height ?? minHeight),
})}{' '}
{t(($) => $['resize.nodeHelp'])}
</span>
</div>
)
}

View File

@ -1,6 +1,7 @@
import type { PropsWithChildren } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
@ -788,7 +789,122 @@ describe('workflow-panel index', () => {
expect(mockHandleStop).toHaveBeenCalledTimes(1)
})
it('should persist user resize changes and compress oversized panel widths', async () => {
it('should resize the node panel with the keyboard, persist its width, and allow focus to leave', async () => {
const user = userEvent.setup()
renderWorkflowComponent(
<>
<button>Before panel</button>
<BasePanel id="node-resize" data={createData() as never}>
<div>panel-child</div>
</BasePanel>
</>,
{
initialStoreState: {
workflowCanvasWidth: 1200,
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
await user.tab()
expect(screen.getByRole('button', { name: 'Before panel' })).toHaveFocus()
await user.tab()
const separator = screen.getByRole('separator', { name: 'workflow.panel.nodePanel' })
expect(separator).toHaveFocus()
expect(separator).toHaveAttribute('aria-orientation', 'vertical')
const panel = document.getElementById(separator.getAttribute('aria-controls')!)!
await user.keyboard('{ArrowLeft}')
expect(panel).toHaveStyle({ width: '488px' })
expect(separator).toHaveAttribute('aria-valuenow', '488')
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}')
expect(panel).toHaveStyle({ width: '520px' })
await user.keyboard('{Shift>}{ArrowRight}{/Shift}{ArrowRight}')
expect(panel).toHaveStyle({ width: '480px' })
expect(separator).toHaveAttribute('aria-valuenow', '480')
await waitFor(() => {
expect(localStorage.getItem('workflow-node-panel-width')).toBe('480')
})
await user.tab()
expect(separator).not.toHaveFocus()
await user.tab({ shift: true })
expect(separator).toHaveFocus()
await user.tab({ shift: true })
expect(screen.getByRole('button', { name: 'Before panel' })).toHaveFocus()
})
it('should constrain keyboard resizing to the available space and keep unrelated keys untouched', async () => {
const user = userEvent.setup()
const onKeyDown = vi.fn()
const { store } = renderWorkflowComponent(
<BasePanel id="node-resize" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
workflowCanvasWidth: 1200,
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
await user.tab()
const separator = screen.getByRole('separator', { name: 'workflow.panel.nodePanel' })
expect(separator).toHaveFocus()
expect(separator).toHaveAttribute('aria-valuemin', '400')
expect(separator).toHaveAttribute('aria-valuemax', '600')
document.addEventListener('keydown', onKeyDown)
try {
await user.keyboard('{Home}{ArrowRight}')
expect(separator).toHaveAttribute('aria-valuenow', '400')
await user.keyboard('{End}{ArrowLeft}')
expect(separator).toHaveAttribute('aria-valuenow', '600')
expect(onKeyDown).not.toHaveBeenCalled()
act(() => store.setState({ otherPanelWidth: 300 }))
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '500'))
expect(separator).toHaveAttribute('aria-valuemax', '500')
await user.keyboard('{Home}{End}')
expect(separator).toHaveAttribute('aria-valuenow', '500')
onKeyDown.mockClear()
await user.keyboard('{ArrowDown}')
expect(separator).toHaveAttribute('aria-valuenow', '500')
expect(onKeyDown).toHaveBeenCalledOnce()
await user.keyboard('{Control>}{ArrowRight}{/Control}')
expect(separator).toHaveAttribute('aria-valuenow', '500')
} finally {
document.removeEventListener('keydown', onKeyDown)
}
})
it('compresses the node panel when the preview grows without replacing the saved node width', async () => {
localStorage.setItem('workflow-node-panel-width', '600')
const { store } = renderWorkflowComponent(
<BasePanel id="node-resize" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
workflowCanvasWidth: 1400,
nodePanelWidth: 600,
otherPanelWidth: 400,
},
},
)
const handle = screen.getByRole('separator', { name: 'workflow.panel.nodePanel' })
expect(handle).toHaveAttribute('aria-valuenow', '600')
act(() => store.getState().setOtherPanelWidth(600))
await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '400'))
expect(handle).toHaveAttribute('aria-valuemax', '400')
expect(localStorage.getItem('workflow-node-panel-width')).toBe('600')
})
it('should compress oversized panel widths', async () => {
const { container } = renderWorkflowComponent(
<BasePanel id="node-resize" data={createData() as never}>
<div>panel-child</div>

View File

@ -9,11 +9,12 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { debounce } from 'es-toolkit/compat'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { cloneElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { cloneElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import ResizeHandle from '@/app/components/base/resize-handle'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import {
@ -94,6 +95,7 @@ type BasePanelProps = {
const BasePanel: FC<BasePanelProps> = ({ id, data, children }) => {
const { t } = useTranslation()
const panelId = useId()
const language = useLanguage()
const appId = useStore((s) => s.appId)
const { data: userProfile } = useSuspenseQuery({
@ -553,13 +555,21 @@ const BasePanel: FC<BasePanelProps> = ({ id, data, children }) => {
} as CSSProperties
}
>
<div
<ResizeHandle
ref={triggerRef}
className="absolute top-0 -left-1 flex h-full w-1 cursor-col-resize resize-x items-center justify-center"
side="left"
value={nodePanelWidth}
min={400}
max={maxNodePanelWidth}
label={t(($) => $['panel.nodePanel'], { ns: 'workflow' })}
controls={panelId}
onResize={handleResize}
className="absolute top-0 -left-1 flex h-full w-1 cursor-col-resize items-center justify-center"
>
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
</div>
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle group-focus-visible/resize:h-full group-focus-visible/resize:bg-state-accent-solid hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
</ResizeHandle>
<Tabs
id={panelId}
ref={containerRef}
value={tabType}
onValueChange={(selectedValue) => setTabType(selectedValue)}

View File

@ -43,7 +43,7 @@ const NoteNode = ({ id, data }: NodeProps<NoteNodeType>) => {
return (
<div
className={cn(
'relative flex flex-col rounded-md border shadow-xs hover:shadow-md',
'group relative flex flex-col rounded-md border shadow-xs hover:shadow-md',
THEME_MAP[theme]!.bg,
data.selected ? THEME_MAP[theme]!.border : 'border-black/5',
)}

View File

@ -0,0 +1,148 @@
import type { ReactNode } from 'react'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { ReactFlowProvider, useStoreApi } from 'reactflow'
import SnippetRunPanel from '@/app/components/snippets/components/snippet-run-panel'
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import DebugAndPreview from '../debug-and-preview'
import Panel from '../index'
import WorkflowPreview from '../workflow-preview'
vi.mock('@/app/components/workflow/hooks/use-workflow-panel-interactions', () => ({
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: () => <div>Selected node details</div>,
}))
function SelectedNodePanel({ children }: { children: ReactNode }) {
const reactFlowStore = useStoreApi()
useEffect(() => {
reactFlowStore.getState().setNodes([createNode({ data: { selected: true } })])
}, [reactFlowStore])
return (
<>
<button
type="button"
onClick={() => {
const { getNodes, setNodes } = reactFlowStore.getState()
setNodes(getNodes().map((node) => ({ ...node, data: { ...node.data, selected: false } })))
}}
>
Deselect node
</button>
<Panel components={{ right: children }} />
</>
)
}
function renderPanels(children: ReactNode) {
return renderWorkflowComponent(
<ReactFlowProvider>
<SelectedNodePanel>{children}</SelectedNodePanel>
</ReactFlowProvider>,
{
initialStoreState: { workflowCanvasWidth: 1400, nodePanelWidth: 600, previewPanelWidth: 400 },
},
)
}
describe('preview width limits inside the workflow Panel', () => {
beforeEach(() => vi.clearAllMocks())
it.each([
['workflow run', <WorkflowPreview key="workflow" />],
['snippet run', <SnippetRunPanel key="snippet" fields={[]} />],
['chat debug', <DebugAndPreview key="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(<DebugAndPreview />)
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', <WorkflowPreview key="workflow" />],
['snippet run', <SnippetRunPanel key="snippet" fields={[]} />],
['chat debug', <DebugAndPreview key="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')
},
)
})

View File

@ -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)
},
)
})

View File

@ -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<typeof renderWithWorkflowStore>[0],
options?: Parameters<typeof renderWithWorkflowStore>[1],
) => renderWithWorkflowStore(<ReactFlowProvider>{ui}</ReactFlowProvider>, 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(<WorkflowPreview />, {
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(<WorkflowPreview />, {

View File

@ -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(<DebugAndPreview />, {
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(<DebugAndPreview />, {

View File

@ -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 (
<div className="relative h-full">
<div
<ResizeHandle
ref={triggerRef}
className="absolute top-0 -left-1 flex h-full w-1 cursor-col-resize resize-x items-center justify-center"
side="left"
value={panelWidth}
min={400}
max={maxPanelWidth}
controls={panelId}
label={t(($) => $['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"
>
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
</div>
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle group-focus-visible/resize:h-full group-focus-visible/resize:bg-state-accent-solid hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
</ResizeHandle>
<div
id={panelId}
ref={containerRef}
className={cn(
'relative flex h-full flex-col rounded-l-2xl border border-r-0 border-components-panel-border bg-chatbot-bg shadow-xl',

View File

@ -8,6 +8,7 @@ import dynamic from '@/next/dynamic'
import { Panel as NodePanel } from '../nodes'
import { useStore } from '../store'
import EnvPanel from './env-panel'
import { getPreviewPanelMaxWidth } from './panel-width'
const VersionHistoryPanel = dynamic(
() => import('@/app/components/workflow/panel/version-history-panel'),
@ -102,19 +103,10 @@ const Panel: FC<PanelProps> = ({ 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])

View File

@ -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)
}

View File

@ -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 (
<div
id={panelId}
className="relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
style={{ width: `${panelWidth}px` }}
>
<div
className="absolute top-1/2 bottom-0 left-0.75 z-50 h-6 w-0.75 cursor-col-resize rounded-sm bg-gray-300"
<ResizeHandle
side="left"
value={panelWidth}
min={400}
max={maxPanelWidth}
controls={panelId}
label={t(($) => $['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}
/>
<div className="flex items-center justify-between p-4 pb-1 text-base font-semibold text-text-primary">

View File

@ -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: () => <div>Variables</div> }))
describe('variable inspect index', () => {
it('grows upward with the keyboard and persists its height', async () => {
const user = userEvent.setup()
renderWorkflowComponent(<VariableInspectPanel />, {
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(<VariableInspectPanel />, {
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(<VariableInspectPanel />, {
initialStoreState: {

View File

@ -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 (
<div className={cn('relative pb-1')}>
<div
<ResizeHandle
ref={triggerRef}
className="absolute -top-1 left-0 flex h-1 w-full cursor-row-resize resize-y items-center justify-center"
side="top"
value={variableInspectPanelHeight}
min={120}
max={maxHeight}
controls={panelId}
label={t(($) => $['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"
>
<div className="h-0.5 w-10 rounded-xs bg-state-base-handle hover:w-full hover:bg-state-accent-solid active:w-full active:bg-state-accent-solid"></div>
</div>
<div className="h-0.5 w-10 rounded-xs bg-state-base-handle group-focus-visible/resize:w-full group-focus-visible/resize:bg-state-accent-solid hover:w-full hover:bg-state-accent-solid active:w-full active:bg-state-accent-solid"></div>
</ResizeHandle>
<div
id={panelId}
ref={containerRef}
className={cn(
'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl',

View File

@ -103,6 +103,7 @@ describe('SkillDetailPage navigation', () => {
})
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')
})

View File

@ -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 = (
<span aria-hidden className="i-ri-question-line size-4 shrink-0" />
@ -176,6 +176,7 @@ export function FileTree({
const queryClient = useQueryClient()
const sidebarRef = useRef<HTMLElement>(null)
const filesTitleId = useId()
const sidebarPanelId = useId()
const uploadInputRef = useRef<HTMLInputElement>(null)
const [inlineAction, setInlineAction] = useState<FileTreeInlineAction>()
const [draggingPaths, setDraggingPaths] = useState<string[]>([])
@ -288,15 +289,17 @@ export function FileTree({
)
const handleSidebarResizeKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
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}
>
<div
id={sidebarPanelId}
data-testid="skill-detail-sidebar"
className={cn(
'group/sidebar relative flex min-h-0 flex-col rounded-lg bg-components-panel-bg',
@ -1174,6 +1178,8 @@ export function FileTree({
aria-valuemax={skillSidebarMaxWidth}
aria-valuemin={skillSidebarMinWidth}
aria-valuenow={sidebarWidth}
aria-valuetext={t(($) => $['resize.width'], { ns: 'common', width: sidebarWidth })}
aria-controls={sidebarPanelId}
tabIndex={0}
className="group/resize absolute top-0 -right-2 z-40 flex h-full w-4 cursor-col-resize touch-none items-center justify-center outline-hidden"
onKeyDown={handleSidebarResizeKeyDown}

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": ".",
"provider.encrypted.front": "سيتم تشفير مفتاح API الخاص بك وتخزينه باستخدام تقنية",
"provider.validating": "جارٍ التحقق من المفتاح...",
"resize.editor": "تغيير حجم المحرر",
"resize.editorHelp": "استخدم السهمين لأعلى ولأسفل لتغيير الحجم. اضغط مطولاً على Shift لخطوات أكبر. يعيد Home أو Enter الحد الأدنى للارتفاع.",
"resize.height": "الارتفاع {{height}} بكسل",
"resize.node": "تغيير حجم العقدة",
"resize.nodeHelp": "استخدم مفاتيح الأسهم لتغيير الحجم. اضغط مطولاً على Shift لخطوات أكبر. يصغّر Home أو Enter الحجم ليتسع للمحتوى.",
"resize.size": "العرض {{width}} بكسل، الارتفاع {{height}} بكسل",
"resize.width": "العرض {{width}} بكسل",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "الفوترة",
"settings.customEndpoint": "نقطة نهاية مخصصة",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "العقدة غير موجودة: {{nodeId}}",
"panel.locateNodeSuccess": "تم تحديد موقع العقدة: {{title}}",
"panel.nextStep": "الخطوة التالية",
"panel.nodePanel": "لوحة إعدادات العقدة",
"panel.openWorkflow": "فتح سير العمل",
"panel.optional": "(اختياري)",
"panel.optional_and_hidden": "(اختياري ومخفي)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " Technologie gespeichert.",
"provider.encrypted.front": "Ihr API-SCHLÜSSEL wird verschlüsselt und mit",
"provider.validating": "Schlüssel wird validiert...",
"resize.editor": "Editorgröße ändern",
"resize.editorHelp": "Mit den Pfeiltasten nach oben und unten die Höhe ändern. Umschalt für größere Schritte halten. Pos1 oder Eingabe stellt die Mindesthöhe wieder her.",
"resize.height": "Höhe: {{height}} Pixel",
"resize.node": "Knotengröße ändern",
"resize.nodeHelp": "Mit den Pfeiltasten die Größe ändern. Umschalt für größere Schritte halten. Pos1 oder Eingabe verkleinert auf die für den Inhalt benötigte Größe.",
"resize.size": "{{width}} Pixel breit, {{height}} Pixel hoch",
"resize.width": "Breite: {{width}} Pixel",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Abrechnung",
"settings.customEndpoint": "Benutzerdefinierter Endpunkt",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Knoten nicht gefunden: {{nodeId}}",
"panel.locateNodeSuccess": "Knoten gefunden: {{title}}",
"panel.nextStep": "Nächster Schritt",
"panel.nodePanel": "Knotenkonfiguration",
"panel.openWorkflow": "Workflow öffnen",
"panel.optional": "(optional)",
"panel.optional_and_hidden": "(optional & hidden)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " technology.",
"provider.encrypted.front": "Your API KEY will be encrypted and stored using",
"provider.validating": "Validating key...",
"resize.editor": "Resize editor",
"resize.editorHelp": "Use Up and Down to resize. Hold Shift for larger steps. Home or Enter resets to the minimum height.",
"resize.height": "Height: {{height}} pixels",
"resize.node": "Resize node",
"resize.nodeHelp": "Use arrow keys to resize. Hold Shift for larger steps. Home or Enter shrinks to fit the contents.",
"resize.size": "{{width}} pixels wide, {{height}} pixels high",
"resize.width": "Width: {{width}} pixels",
"settings.agentStrategy": "Agent Strategy",
"settings.billing": "Billing",
"settings.customEndpoint": "Custom Endpoint",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Node not found: {{nodeId}}",
"panel.locateNodeSuccess": "Located node: {{title}}",
"panel.nextStep": "Next Step",
"panel.nodePanel": "Node configuration panel",
"panel.openWorkflow": "Open Workflow",
"panel.optional": "(optional)",
"panel.optional_and_hidden": "(optional & hidden)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " tecnología.",
"provider.encrypted.front": "Tu CLAVE API será encriptada y almacenada usando",
"provider.validating": "Validando clave...",
"resize.editor": "Cambiar tamaño del editor",
"resize.editorHelp": "Usa las flechas arriba y abajo para cambiar el tamaño. Mantén Mayús para pasos mayores. Inicio o Intro restablece la altura mínima.",
"resize.height": "Alto: {{height}} píxeles",
"resize.node": "Cambiar tamaño del nodo",
"resize.nodeHelp": "Usa las flechas para cambiar el tamaño. Mantén Mayús para pasos mayores. Inicio o Intro reduce el tamaño para ajustarlo al contenido.",
"resize.size": "{{width}} píxeles de ancho, {{height}} píxeles de alto",
"resize.width": "Ancho: {{width}} píxeles",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Facturación",
"settings.customEndpoint": "Endpoint personalizado",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Nodo no encontrado: {{nodeId}}",
"panel.locateNodeSuccess": "Nodo localizado: {{title}}",
"panel.nextStep": "Siguiente paso",
"panel.nodePanel": "Panel de configuración del nodo",
"panel.openWorkflow": "Abrir flujo de trabajo",
"panel.optional": "(opcional)",
"panel.optional_and_hidden": "(opcional y oculto)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " رمزگذاری و ذخیره خواهد شد.",
"provider.encrypted.front": "کلید API شما با استفاده از فناوری",
"provider.validating": "در حال اعتبارسنجی کلید...",
"resize.editor": "تغییر اندازهٔ ویرایشگر",
"resize.editorHelp": "با کلیدهای جهت بالا و پایین اندازه را تغییر دهید. برای گام‌های بزرگ‌تر Shift را نگه دارید. Home یا Enter حداقل ارتفاع را بازمی‌گرداند.",
"resize.height": "ارتفاع: {{height}} پیکسل",
"resize.node": "تغییر اندازهٔ گره",
"resize.nodeHelp": "با کلیدهای جهت اندازه را تغییر دهید. برای گام‌های بزرگ‌تر Shift را نگه دارید. Home یا Enter اندازه را تا حد لازم برای نمایش محتوا کوچک می‌کند.",
"resize.size": "عرض {{width}} پیکسل، ارتفاع {{height}} پیکسل",
"resize.width": "عرض: {{width}} پیکسل",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "صورتحساب",
"settings.customEndpoint": "نقطه پایانی سفارشی",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "گره پیدا نشد: {{nodeId}}",
"panel.locateNodeSuccess": "گره پیدا شد: {{title}}",
"panel.nextStep": "مرحله بعدی",
"panel.nodePanel": "پنل تنظیمات گره",
"panel.openWorkflow": "باز کردن گردش کار",
"panel.optional": "(اختیاری)",
"panel.optional_and_hidden": "(اختیاری و مخفی)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": "technologie.",
"provider.encrypted.front": "Votre clé API sera chiffrée et stockée en utilisant",
"provider.validating": "Validation de la clé...",
"resize.editor": "Redimensionner léditeur",
"resize.editorHelp": "Utilisez les flèches haut et bas pour redimensionner. Maintenez Maj pour de plus grands pas. Début ou Entrée rétablit la hauteur minimale.",
"resize.height": "Hauteur : {{height}} pixels",
"resize.node": "Redimensionner le nœud",
"resize.nodeHelp": "Utilisez les flèches pour redimensionner. Maintenez Maj pour de plus grands pas. Début ou Entrée réduit la taille au minimum nécessaire au contenu.",
"resize.size": "{{width}} pixels de large, {{height}} pixels de haut",
"resize.width": "Largeur : {{width}} pixels",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Facturation",
"settings.customEndpoint": "Point de terminaison personnalisé",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Nœud introuvable : {{nodeId}}",
"panel.locateNodeSuccess": "Nœud localisé : {{title}}",
"panel.nextStep": "Étape suivante",
"panel.nodePanel": "Panneau de configuration du nœud",
"panel.openWorkflow": "Ouvrir le flux de travail",
"panel.optional": "(facultatif)",
"panel.optional_and_hidden": "(optionnel et caché)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " तकनीक का उपयोग करके।",
"provider.encrypted.front": "आपकी एपीआई कुंजी को एन्क्रिप्ट किया जाएगा और संग्रहीत किया जाएगा",
"provider.validating": "कुंजी का सत्यापन हो रहा है...",
"resize.editor": "एडिटर का आकार बदलें",
"resize.editorHelp": "आकार बदलने के लिए ऊपर और नीचे की तीर कुंजियाँ दबाएँ। बड़े बदलाव के लिए Shift दबाए रखें। Home या Enter न्यूनतम ऊँचाई बहाल करता है।",
"resize.height": "ऊँचाई: {{height}} पिक्सेल",
"resize.node": "नोड का आकार बदलें",
"resize.nodeHelp": "आकार बदलने के लिए तीर कुंजियाँ दबाएँ। बड़े बदलाव के लिए Shift दबाए रखें। Home या Enter आकार को सामग्री समाने लायक न्यूनतम आकार तक घटाता है।",
"resize.size": "चौड़ाई {{width}} पिक्सेल, ऊँचाई {{height}} पिक्सेल",
"resize.width": "चौड़ाई: {{width}} पिक्सेल",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "बिलिंग",
"settings.customEndpoint": "कस्टम एंडपॉइंट",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "नोड नहीं मिला: {{nodeId}}",
"panel.locateNodeSuccess": "नोड मिला: {{title}}",
"panel.nextStep": "अगला कदम",
"panel.nodePanel": "नोड कॉन्फ़िगरेशन पैनल",
"panel.openWorkflow": "वर्कफ़्लो खोलें",
"panel.optional": "(वैकल्पिक)",
"panel.optional_and_hidden": "(वैकल्पिक और छिपा हुआ)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": "Teknologi.",
"provider.encrypted.front": "API KEY Anda akan dienkripsi dan disimpan menggunakan",
"provider.validating": "Memvalidasi kunci...",
"resize.editor": "Ubah ukuran editor",
"resize.editorHelp": "Gunakan panah atas dan bawah untuk mengubah ukuran. Tahan Shift untuk langkah lebih besar. Home atau Enter mengembalikan tinggi minimum.",
"resize.height": "Tinggi: {{height}} piksel",
"resize.node": "Ubah ukuran node",
"resize.nodeHelp": "Gunakan tombol panah untuk mengubah ukuran. Tahan Shift untuk langkah lebih besar. Home atau Enter memperkecil ukuran agar pas dengan konten.",
"resize.size": "Lebar {{width}} piksel, tinggi {{height}} piksel",
"resize.width": "Lebar: {{width}} piksel",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Penagihan",
"settings.customEndpoint": "Endpoint Kustom",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Node tidak ditemukan: {{nodeId}}",
"panel.locateNodeSuccess": "Node ditemukan: {{title}}",
"panel.nextStep": "Langkah Berikutnya",
"panel.nodePanel": "Panel konfigurasi node",
"panel.openWorkflow": "Buka Alur Kerja",
"panel.optional": "(opsional)",
"panel.optional_and_hidden": "(opsional & tersembunyi)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": ".",
"provider.encrypted.front": "La tua API KEY sarà crittografata e archiviata utilizzando la tecnologia",
"provider.validating": "Convalida chiave in corso...",
"resize.editor": "Ridimensiona editor",
"resize.editorHelp": "Usa le frecce su e giù per ridimensionare. Tieni premuto Maiusc per incrementi maggiori. Home o Invio ripristina laltezza minima.",
"resize.height": "Altezza: {{height}} pixel",
"resize.node": "Ridimensiona nodo",
"resize.nodeHelp": "Usa le frecce per ridimensionare. Tieni premuto Maiusc per incrementi maggiori. Home o Invio riduce le dimensioni adattandole al contenuto.",
"resize.size": "{{width}} pixel di larghezza, {{height}} pixel di altezza",
"resize.width": "Larghezza: {{width}} pixel",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Fatturazione",
"settings.customEndpoint": "Endpoint personalizzato",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Nodo non trovato: {{nodeId}}",
"panel.locateNodeSuccess": "Nodo localizzato: {{title}}",
"panel.nextStep": "Prossimo Passo",
"panel.nodePanel": "Pannello di configurazione del nodo",
"panel.openWorkflow": "Apri flusso di lavoro",
"panel.optional": "(opzionale)",
"panel.optional_and_hidden": "(opzionale e nascosto)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": "技術を使用して暗号化および保存されます。",
"provider.encrypted.front": "API KEY は",
"provider.validating": "キーの検証中...",
"resize.editor": "エディターのサイズを変更",
"resize.editorHelp": "上下の矢印キーで高さを変更します。Shift を押しながら操作すると変更幅が大きくなります。Home または Enter で最小の高さに戻します。",
"resize.height": "高さ:{{height}}ピクセル",
"resize.node": "ノードのサイズを変更",
"resize.nodeHelp": "矢印キーでサイズを変更します。Shift を押しながら操作すると変更幅が大きくなります。Home または Enter で内容が収まる最小サイズに縮小します。",
"resize.size": "幅 {{width}} ピクセル、高さ {{height}} ピクセル",
"resize.width": "幅:{{width}}ピクセル",
"settings.agentStrategy": "エージェント戦略",
"settings.billing": "請求",
"settings.customEndpoint": "カスタム API",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "ノードが見つかりません:{{nodeId}}",
"panel.locateNodeSuccess": "ノードを特定しました:{{title}}",
"panel.nextStep": "次のステップ",
"panel.nodePanel": "ノード設定パネル",
"panel.openWorkflow": "ワークフローを開く",
"panel.optional": "(任意)",
"panel.optional_and_hidden": "(オプションおよび非表示)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": "기술을 사용하여 암호화 및 저장됩니다.",
"provider.encrypted.front": "API KEY 는",
"provider.validating": "키를 확인하는 중...",
"resize.editor": "편집기 크기 조절",
"resize.editorHelp": "위아래 방향키로 높이를 조절합니다. Shift를 누르면 조절 폭이 커집니다. Home 또는 Enter로 최소 높이로 되돌립니다.",
"resize.height": "높이: {{height}}픽셀",
"resize.node": "노드 크기 조절",
"resize.nodeHelp": "방향키로 크기를 조절합니다. Shift를 누르면 조절 폭이 커집니다. Home 또는 Enter로 내용에 맞는 최소 크기로 줄입니다.",
"resize.size": "너비 {{width}}픽셀, 높이 {{height}}픽셀",
"resize.width": "너비: {{width}}픽셀",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "청구",
"settings.customEndpoint": "사용자 지정 엔드포인트",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "노드를 찾을 수 없습니다: {{nodeId}}",
"panel.locateNodeSuccess": "노드를 찾았습니다: {{title}}",
"panel.nextStep": "다음 단계",
"panel.nodePanel": "노드 설정 패널",
"panel.openWorkflow": "워크플로 열기",
"panel.optional": "(선택사항)",
"panel.optional_and_hidden": "(선택 사항 및 숨김)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " ເຕັກໂນໂລຊີ.",
"provider.encrypted.front": "API KEY ຂອງທ່ານຈະຖືກເຂົ້າລະຫັດ ແລະ ເກັບຮັກສາໂດຍໃຊ້",
"provider.validating": "ກຳລັງກວດສອບ Key...",
"resize.editor": "ປັບຂະໜາດຕົວແກ້ໄຂ",
"resize.editorHelp": "ໃຊ້ປຸ່ມລູກສອນຂຶ້ນ ແລະ ລົງເພື່ອປັບຂະໜາດ. ກົດ Shift ຄ້າງໄວ້ເພື່ອປັບເທື່ອລະຫຼາຍຂຶ້ນ. Home ຫຼື Enter ຈະຄືນຄ່າຄວາມສູງຕ່ຳສຸດ.",
"resize.height": "ຄວາມສູງ: {{height}} ພິກເຊລ",
"resize.node": "ປັບຂະໜາດໂນດ",
"resize.nodeHelp": "ໃຊ້ປຸ່ມລູກສອນເພື່ອປັບຂະໜາດ. ກົດ Shift ຄ້າງໄວ້ເພື່ອປັບເທື່ອລະຫຼາຍຂຶ້ນ. Home ຫຼື Enter ຈະຫຍໍ້ໃຫ້ພໍດີກັບເນື້ອຫາ.",
"resize.size": "ກວ້າງ {{width}} ພິກເຊລ, ສູງ {{height}} ພິກເຊລ",
"resize.width": "ຄວາມກວ້າງ: {{width}} ພິກເຊລ",
"settings.agentStrategy": "ກົນລະຍຸດຕົວແທນ",
"settings.billing": "ການຊຳລະເງິນ",
"settings.customEndpoint": "ຈຸດເຊື່ອມຕໍ່ແບບກຳນົດເອງ",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "ບໍ່ພົບ node: {{nodeId}}",
"panel.locateNodeSuccess": "ທີ່ຢູ່: {{title}}",
"panel.nextStep": "ຂັ້ນຕອນຖັດໄປ",
"panel.nodePanel": "ແຜງຕັ້ງຄ່າໂນດ",
"panel.openWorkflow": "ເປີດ Workflow",
"panel.optional": "(ເລືອກໄດ້)",
"panel.optional_and_hidden": "(ເລືອກໄດ້ & ເຊື່ອງໄວ້)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " technology.",
"provider.encrypted.front": "Your API KEY will be encrypted and stored using",
"provider.validating": "Validating key...",
"resize.editor": "Editorformaat wijzigen",
"resize.editorHelp": "Gebruik de pijlen omhoog en omlaag om het formaat te wijzigen. Houd Shift ingedrukt voor grotere stappen. Home of Enter herstelt de minimale hoogte.",
"resize.height": "Hoogte: {{height}} pixels",
"resize.node": "Knooppuntformaat wijzigen",
"resize.nodeHelp": "Gebruik de pijltjestoetsen om het formaat te wijzigen. Houd Shift ingedrukt voor grotere stappen. Home of Enter verkleint het formaat tot de inhoud past.",
"resize.size": "{{width}} pixels breed, {{height}} pixels hoog",
"resize.width": "Breedte: {{width}} pixels",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Billing",
"settings.customEndpoint": "Aangepast eindpunt",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Node niet gevonden: {{nodeId}}",
"panel.locateNodeSuccess": "Node gevonden: {{title}}",
"panel.nextStep": "Next Step",
"panel.nodePanel": "Configuratiepaneel voor knooppunten",
"panel.openWorkflow": "Open Workflow",
"panel.optional": "(optional)",
"panel.optional_and_hidden": "(optional & hidden)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " technologii.",
"provider.encrypted.front": "Twój KLUCZ API będzie szyfrowany i przechowywany za pomocą",
"provider.validating": "Weryfikowanie klucza...",
"resize.editor": "Zmień rozmiar edytora",
"resize.editorHelp": "Użyj strzałek w górę i w dół, aby zmienić rozmiar. Przytrzymaj Shift, aby zwiększyć krok. Home lub Enter przywraca minimalną wysokość.",
"resize.height": "Wysokość: {{height}} pikseli",
"resize.node": "Zmień rozmiar węzła",
"resize.nodeHelp": "Użyj strzałek, aby zmienić rozmiar. Przytrzymaj Shift, aby zwiększyć krok. Home lub Enter zmniejsza rozmiar do wymiarów mieszczących zawartość.",
"resize.size": "Szerokość {{width}} pikseli, wysokość {{height}} pikseli",
"resize.width": "Szerokość: {{width}} pikseli",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Rozliczenia",
"settings.customEndpoint": "Niestandardowy punkt końcowy",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Nie znaleziono węzła: {{nodeId}}",
"panel.locateNodeSuccess": "Zlokalizowano węzeł: {{title}}",
"panel.nextStep": "Następny krok",
"panel.nodePanel": "Panel konfiguracji węzła",
"panel.openWorkflow": "Otwórz przepływ pracy",
"panel.optional": "(opcjonalne)",
"panel.optional_and_hidden": "(opcjonalne i ukryte)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " tecnologia.",
"provider.encrypted.front": "Sua CHAVE DA API será criptografada e armazenada usando",
"provider.validating": "Validando chave...",
"resize.editor": "Redimensionar editor",
"resize.editorHelp": "Use as setas para cima e para baixo para redimensionar. Segure Shift para passos maiores. Home ou Enter restaura a altura mínima.",
"resize.height": "Altura: {{height}} pixels",
"resize.node": "Redimensionar nó",
"resize.nodeHelp": "Use as setas para redimensionar. Segure Shift para passos maiores. Home ou Enter reduz o tamanho para acomodar o conteúdo.",
"resize.size": "{{width}} pixels de largura, {{height}} pixels de altura",
"resize.width": "Largura: {{width}} pixels",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Faturamento",
"settings.customEndpoint": "Endpoint personalizado",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Nó não encontrado: {{nodeId}}",
"panel.locateNodeSuccess": "Nó localizado: {{title}}",
"panel.nextStep": "Próximo passo",
"panel.nodePanel": "Painel de configuração do nó",
"panel.openWorkflow": "Abrir fluxo de trabalho",
"panel.optional": "(opcional)",
"panel.optional_and_hidden": "(opcional & oculto)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " tehnologie.",
"provider.encrypted.front": "Cheia dvs. API va fi criptată și stocată folosind",
"provider.validating": "Se validează cheia...",
"resize.editor": "Redimensionează editorul",
"resize.editorHelp": "Folosește săgețile sus și jos pentru redimensionare. Ține apăsat Shift pentru pași mai mari. Home sau Enter restabilește înălțimea minimă.",
"resize.height": "Înălțime: {{height}} pixeli",
"resize.node": "Redimensionează nodul",
"resize.nodeHelp": "Folosește săgețile pentru redimensionare. Ține apăsat Shift pentru pași mai mari. Home sau Enter micșorează dimensiunea cât să încapă conținutul.",
"resize.size": "{{width}} pixeli lățime, {{height}} pixeli înălțime",
"resize.width": "Lățime: {{width}} pixeli",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Facturare",
"settings.customEndpoint": "Endpoint personalizat",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Nod negăsit: {{nodeId}}",
"panel.locateNodeSuccess": "Nod localizat: {{title}}",
"panel.nextStep": "Pasul următor",
"panel.nodePanel": "Panoul de configurare a nodului",
"panel.openWorkflow": "Deschide fluxul de lucru",
"panel.optional": "(opțional)",
"panel.optional_and_hidden": "(opțional și ascuns)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " технологии.",
"provider.encrypted.front": "Ваш API-ключ будет зашифрован и сохранен с использованием",
"provider.validating": "Проверка ключа...",
"resize.editor": "Изменить размер редактора",
"resize.editorHelp": "Меняйте высоту стрелками вверх и вниз. Удерживайте Shift для большего шага. Home или Enter возвращает минимальную высоту.",
"resize.height": "Высота: {{height}} пикселей",
"resize.node": "Изменить размер узла",
"resize.nodeHelp": "Меняйте размер стрелками. Удерживайте Shift для большего шага. Home или Enter уменьшает размер до минимального, вмещающего содержимое.",
"resize.size": "Ширина {{width}} пикселей, высота {{height}} пикселей",
"resize.width": "Ширина: {{width}} пикселей",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Оплата",
"settings.customEndpoint": "Пользовательская конечная точка",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Узел не найден: {{nodeId}}",
"panel.locateNodeSuccess": "Узел найден: {{title}}",
"panel.nextStep": "Следующий шаг",
"panel.nodePanel": "Панель настройки узла",
"panel.openWorkflow": "Открыть рабочий процесс",
"panel.optional": "(необязательно)",
"panel.optional_and_hidden": "(необязательно и скрыто)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " tehnologije.",
"provider.encrypted.front": "Vaš API ključ bo šifriran in shranjen z uporabo",
"provider.validating": "Preverjam ključ...",
"resize.editor": "Spremeni velikost urejevalnika",
"resize.editorHelp": "S puščicama gor in dol spremenite velikost. Za večje korake pridržite Shift. Home ali Enter povrne najmanjšo višino.",
"resize.height": "Višina: {{height}} slikovnih pik",
"resize.node": "Spremeni velikost vozlišča",
"resize.nodeHelp": "S puščičnimi tipkami spremenite velikost. Za večje korake pridržite Shift. Home ali Enter zmanjša velikost tako, da se vsebina še prilega.",
"resize.size": "Širina {{width}} slikovnih pik, višina {{height}} slikovnih pik",
"resize.width": "Širina: {{width}} slikovnih pik",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Zaračunavanje",
"settings.customEndpoint": "Končna točka po meri",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Vozlišče ni najdeno: {{nodeId}}",
"panel.locateNodeSuccess": "Vozlišče najdeno: {{title}}",
"panel.nextStep": "Naslednji korak",
"panel.nodePanel": "Podokno za nastavitev vozlišča",
"panel.openWorkflow": "Odpri delovni tok",
"panel.optional": "(neobvezno)",
"panel.optional_and_hidden": "(neobvezno in skrito)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": "เทคโนโลยี ",
"provider.encrypted.front": "คีย์ API ของคุณจะถูกเข้ารหัสและจัดเก็บโดยใช้",
"provider.validating": "กําลังตรวจสอบความถูกต้องของคีย์...",
"resize.editor": "ปรับขนาดตัวแก้ไข",
"resize.editorHelp": "ใช้ปุ่มลูกศรขึ้นและลงเพื่อปรับขนาด กด Shift ค้างไว้เพื่อปรับทีละมากขึ้น Home หรือ Enter จะคืนค่าความสูงต่ำสุด",
"resize.height": "ความสูง: {{height}} พิกเซล",
"resize.node": "ปรับขนาดโหนด",
"resize.nodeHelp": "ใช้ปุ่มลูกศรเพื่อปรับขนาด กด Shift ค้างไว้เพื่อปรับทีละมากขึ้น Home หรือ Enter จะย่อให้พอดีกับเนื้อหา",
"resize.size": "กว้าง {{width}} พิกเซล สูง {{height}} พิกเซล",
"resize.width": "ความกว้าง: {{width}} พิกเซล",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "เรียก เก็บ เงิน",
"settings.customEndpoint": "ปลายทางแบบกำหนดเอง",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "ไม่พบโหนด: {{nodeId}}",
"panel.locateNodeSuccess": "พบโหนดแล้ว: {{title}}",
"panel.nextStep": "ขั้นตอนถัดไป",
"panel.nodePanel": "แผงการตั้งค่าโหนด",
"panel.openWorkflow": "เปิดเวิร์กโฟลว์",
"panel.optional": "(ไม่บังคับ)",
"panel.optional_and_hidden": "(ตัวเลือก & ซ่อน)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " teknolojisi.",
"provider.encrypted.front": "API ANAHTARINIZ şu kullanılarak şifrelenip saklanacak:",
"provider.validating": "Anahtar doğrulanıyor...",
"resize.editor": "Düzenleyiciyi yeniden boyutlandır",
"resize.editorHelp": "Boyutlandırmak için yukarı ve aşağı oklarını kullanın. Daha büyük adımlar için Shift tuşunu basılı tutun. Home veya Enter en küçük yüksekliğe döndürür.",
"resize.height": "Yükseklik: {{height}} piksel",
"resize.node": "Düğümü yeniden boyutlandır",
"resize.nodeHelp": "Boyutlandırmak için ok tuşlarını kullanın. Daha büyük adımlar için Shift tuşunu basılı tutun. Home veya Enter içeriği sığdıracak en küçük boyuta küçültür.",
"resize.size": "{{width}} piksel genişlik, {{height}} piksel yükseklik",
"resize.width": "Genişlik: {{width}} piksel",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Faturalandırma",
"settings.customEndpoint": "Özel Uç Nokta",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Düğüm bulunamadı: {{nodeId}}",
"panel.locateNodeSuccess": "Düğüm bulundu: {{title}}",
"panel.nextStep": "Sonraki Adım",
"panel.nodePanel": "Düğüm yapılandırma paneli",
"panel.openWorkflow": "İş Akışını Aç",
"panel.optional": "(isteğe bağlı)",
"panel.optional_and_hidden": "(isteğe bağlı ve gizli)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " технології.",
"provider.encrypted.front": "Ваш API-ключ буде зашифрований та збережений за допомогою",
"provider.validating": "Перевірка ключа...",
"resize.editor": "Змінити розмір редактора",
"resize.editorHelp": "Змінюйте висоту стрілками вгору та вниз. Утримуйте Shift для більшого кроку. Home або Enter відновлює мінімальну висоту.",
"resize.height": "Висота: {{height}} пікселів",
"resize.node": "Змінити розмір вузла",
"resize.nodeHelp": "Змінюйте розмір стрілками. Утримуйте Shift для більшого кроку. Home або Enter зменшує розмір до мінімального, що вміщує вміст.",
"resize.size": "Ширина {{width}} пікселів, висота {{height}} пікселів",
"resize.width": "Ширина: {{width}} пікселів",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Виставлення рахунків",
"settings.customEndpoint": "Користувацька кінцева точка",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Вузол не знайдено: {{nodeId}}",
"panel.locateNodeSuccess": "Вузол знайдено: {{title}}",
"panel.nextStep": "Наступний крок",
"panel.nodePanel": "Панель налаштування вузла",
"panel.openWorkflow": "Відкрити робочий процес",
"panel.optional": "(необов'язково)",
"panel.optional_and_hidden": "(необов'язково & приховано)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " công nghệ.",
"provider.encrypted.front": "Khóa API của bạn sẽ được mã hóa và lưu trữ bằng",
"provider.validating": "Đang xác minh khóa...",
"resize.editor": "Đổi kích thước trình soạn thảo",
"resize.editorHelp": "Dùng phím mũi tên lên và xuống để đổi kích thước. Giữ Shift để tăng bước điều chỉnh. Home hoặc Enter khôi phục chiều cao tối thiểu.",
"resize.height": "Chiều cao: {{height}} pixel",
"resize.node": "Đổi kích thước nút",
"resize.nodeHelp": "Dùng các phím mũi tên để đổi kích thước. Giữ Shift để tăng bước điều chỉnh. Home hoặc Enter thu nhỏ vừa đủ chứa nội dung.",
"resize.size": "Rộng {{width}} pixel, cao {{height}} pixel",
"resize.width": "Chiều rộng: {{width}} pixel",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Thanh toán",
"settings.customEndpoint": "Điểm cuối tùy chỉnh",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "Không tìm thấy nút: {{nodeId}}",
"panel.locateNodeSuccess": "Đã định vị nút: {{title}}",
"panel.nextStep": "Bước tiếp theo",
"panel.nodePanel": "Bảng cấu hình nút",
"panel.openWorkflow": "Mở quy trình làm việc",
"panel.optional": "(tùy chọn)",
"panel.optional_and_hidden": "(tùy chọn & ẩn)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " 技术进行加密和存储。",
"provider.encrypted.front": "密钥将使用 ",
"provider.validating": "验证密钥中...",
"resize.editor": "调整编辑器尺寸",
"resize.editorHelp": "使用上下方向键调整高度,按住 Shift 加速。Home 或 Enter 恢复最小高度。",
"resize.height": "高度:{{height}} 像素",
"resize.node": "调整节点尺寸",
"resize.nodeHelp": "使用方向键调整尺寸,按住 Shift 加速。Home 或 Enter 缩小至容纳内容的最小尺寸。",
"resize.size": "宽 {{width}} 像素,高 {{height}} 像素",
"resize.width": "宽度:{{width}} 像素",
"settings.agentStrategy": "Agent 策略",
"settings.billing": "账单",
"settings.customEndpoint": "自定义端点",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "未找到节点:{{nodeId}}",
"panel.locateNodeSuccess": "已定位到节点:{{title}}",
"panel.nextStep": "下一步",
"panel.nodePanel": "节点配置面板",
"panel.openWorkflow": "打开工作流",
"panel.optional": "(选填)",
"panel.optional_and_hidden": "(选填 & 隐藏)",

View File

@ -544,6 +544,13 @@
"provider.encrypted.back": " 技術進行加密和儲存。",
"provider.encrypted.front": "金鑰將使用 ",
"provider.validating": "驗證金鑰中...",
"resize.editor": "調整編輯器尺寸",
"resize.editorHelp": "使用上下方向鍵調整高度,按住 Shift 加速。Home 或 Enter 恢復最小高度。",
"resize.height": "高度:{{height}} 像素",
"resize.node": "調整節點尺寸",
"resize.nodeHelp": "使用方向鍵調整尺寸,按住 Shift 加速。Home 或 Enter 縮小至容納內容的最小尺寸。",
"resize.size": "寬 {{width}} 像素,高 {{height}} 像素",
"resize.width": "寬度:{{width}} 像素",
"settings.agentStrategy": "Agent 策略",
"settings.billing": "賬單",
"settings.customEndpoint": "自訂端點",

View File

@ -998,6 +998,7 @@
"panel.locateNodeNotFound": "未找到節點:{{nodeId}}",
"panel.locateNodeSuccess": "已定位到節點:{{title}}",
"panel.nextStep": "下一步",
"panel.nodePanel": "節點設定面板",
"panel.openWorkflow": "打開工作流程",
"panel.optional": "(選擇性)",
"panel.optional_and_hidden": "(可選且隱藏)",