diff --git a/web/app/components/workflow/nodes/_base/components/editor/base.tsx b/web/app/components/workflow/nodes/_base/components/editor/base.tsx index 6ed582369c..c0545ff01c 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/base.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/base.tsx @@ -84,7 +84,7 @@ const Base: FC = ({ return ( -
+
{title}
(null) + + // Stub a ref-like object so measurements are deterministic. + if (!ref.current) { + Object.defineProperty(ref, 'current', { + value: { clientHeight } as HTMLDivElement, + writable: true, + }) + } + + return useToggleExpend({ ref, hasFooter, isInNode }) +} + +describe('useToggleExpend', () => { + describe('collapsed state', () => { + it('returns empty wrapClassName and zero expand height when collapsed', () => { + const { result } = renderHook(() => useHarness({ clientHeight: 400 })) + + expect(result.current.isExpand).toBe(false) + expect(result.current.wrapClassName).toBe('') + expect(result.current.editorExpandHeight).toBe(0) + }) + }) + + describe('expanded state (node context)', () => { + it('uses fixed positioning inside a workflow node panel', () => { + const { result } = renderHook(() => + useHarness({ isInNode: true, clientHeight: 400 }), + ) + + act(() => { + result.current.setIsExpand(true) + }) + + expect(result.current.isExpand).toBe(true) + expect(result.current.wrapClassName).toContain('fixed') + expect(result.current.wrapClassName).toContain('bg-components-panel-bg') + expect(result.current.wrapStyle).toEqual( + expect.objectContaining({ boxShadow: expect.any(String) }), + ) + }) + }) + + describe('expanded state (execution-log / webapp context)', () => { + it('fills its positioned ancestor edge-to-edge without hardcoded offsets', () => { + const { result } = renderHook(() => + useHarness({ isInNode: false, clientHeight: 400 }), + ) + + act(() => { + result.current.setIsExpand(true) + }) + + // The expanded panel must fill the nearest positioned ancestor entirely + // (absolute + inset-0). Previously it used hardcoded `top-[52px]` which + // assumed a 52px header that does not exist in the conversation-log + // layout, causing the expanded panel to overlap the status bar above + // the editor (#34887). + expect(result.current.wrapClassName).toContain('absolute') + expect(result.current.wrapClassName).toContain('inset-0') + expect(result.current.wrapClassName).not.toMatch(/top-\[\d+px\]/) + expect(result.current.wrapClassName).not.toMatch(/left-\d+/) + expect(result.current.wrapClassName).not.toMatch(/right-\d+/) + expect(result.current.wrapClassName).toContain('bg-components-panel-bg') + }) + }) + + describe('expanded state height math', () => { + it('subtracts the 29px chrome when hasFooter is false', () => { + const { result } = renderHook(() => + useHarness({ hasFooter: false, clientHeight: 400 }), + ) + + act(() => { + result.current.setIsExpand(true) + }) + + // 400 (clientHeight) - 29 (title bar) = 371 + expect(result.current.editorExpandHeight).toBe(371) + }) + + it('subtracts the 56px chrome when hasFooter is true', () => { + const { result } = renderHook(() => + useHarness({ hasFooter: true, clientHeight: 400 }), + ) + + act(() => { + result.current.setIsExpand(true) + }) + + // 400 (clientHeight) - 56 (title bar + footer) = 344 + expect(result.current.editorExpandHeight).toBe(344) + }) + + it('never returns a negative height even if chrome exceeds wrap', () => { + const { result } = renderHook(() => + useHarness({ hasFooter: true, clientHeight: 20 }), + ) + + act(() => { + result.current.setIsExpand(true) + }) + + // 20 - 56 would be -36; clamped to 0. + expect(result.current.editorExpandHeight).toBe(0) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/hooks/use-toggle-expend.ts b/web/app/components/workflow/nodes/_base/hooks/use-toggle-expend.ts index c123c00e2d..1afeb8db12 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-toggle-expend.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-toggle-expend.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useLayoutEffect, useState } from 'react' type Params = { ref?: React.RefObject @@ -6,30 +6,62 @@ type Params = { isInNode?: boolean } +// Chrome (title bar + optional footer) heights subtracted from the wrap so +// the editor body never paints underneath its own controls. +const CHROME_HEIGHT_WITH_FOOTER = 56 +const CHROME_HEIGHT_WITHOUT_FOOTER = 29 + +/** + * Controls the expand/collapse behavior of the code editor wrapper used across + * workflow nodes and execution-log panels. + * + * Returns: + * - `wrapClassName` / `wrapStyle` — positioning + shadow applied to the outer + * wrapper when the editor is expanded. + * - `editorExpandHeight` — height for the editor body (wrap minus chrome). + * - `isExpand` / `setIsExpand` — state + setter for the consumer. + * + * Height is measured via `useLayoutEffect` so the first expanded render + * already has the correct value — the previous `useEffect` implementation + * left the editor at the collapsed height for one paint on first expand. + */ const useToggleExpend = ({ ref, hasFooter = true, isInNode }: Params) => { const [isExpand, setIsExpand] = useState(false) - const [wrapHeight, setWrapHeight] = useState(ref?.current?.clientHeight) - const editorExpandHeight = isExpand ? wrapHeight! - (hasFooter ? 56 : 29) : 0 - useEffect(() => { + const [wrapHeight, setWrapHeight] = useState(undefined) + + useLayoutEffect(() => { if (!ref?.current) return - setWrapHeight(ref.current?.clientHeight) - }, [isExpand]) + setWrapHeight(ref.current.clientHeight) + }, [isExpand, ref]) + + const chromeHeight = hasFooter ? CHROME_HEIGHT_WITH_FOOTER : CHROME_HEIGHT_WITHOUT_FOOTER + const editorExpandHeight = isExpand && wrapHeight !== undefined + ? Math.max(0, wrapHeight - chromeHeight) + : 0 const wrapClassName = (() => { if (!isExpand) return '' if (isInNode) - return 'fixed z-10 right-[9px] top-[166px] bottom-[8px] p-4 bg-components-panel-bg rounded-xl' + return 'fixed z-10 right-[9px] top-[166px] bottom-[8px] p-4 bg-components-panel-bg rounded-xl' - return 'absolute z-10 left-4 right-6 top-[52px] bottom-0 pb-4 bg-components-panel-bg' + // Fill the nearest positioned ancestor entirely. Previously hardcoded + // `top-[52px] left-4 right-6` offsets assumed a 52px header above the + // scroll container — that assumption no longer holds in the conversation + // log (result-panel) layout, where the status bar above the editor is + // taller than 52px, causing the expanded panel to partially overlap the + // status bar (issue #34887). + return 'absolute z-10 inset-0 pb-4 bg-components-panel-bg' })() + const wrapStyle = isExpand ? { boxShadow: '0px 0px 12px -4px rgba(16, 24, 40, 0.05), 0px -3px 6px -2px rgba(16, 24, 40, 0.03)', } : {} + return { wrapClassName, wrapStyle,