diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/panel.browser.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.browser.spec.tsx new file mode 100644 index 00000000000..8b7be13c8b5 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.browser.spec.tsx @@ -0,0 +1,124 @@ +import type { ConversationItem, SessionView } from '../types' +import { createStore, Provider } from 'jotai' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import DifyBuilderPanel from '../panel' +import { difyBuilderConversationAtom, difyBuilderSessionViewAtom } from '../session/state' +import { difyBuilderRuntimeAtom } from '../store' + +const mocks = vi.hoisted(() => ({ + closePanel: vi.fn(), +})) + +const sessionView: SessionView = { + actions: [{ id: 'approve_plan', label: 'Approve plan', kind: 'primary' }], + app_revision: { observed: 'hash-1', current: 'hash-1', conflicted: false }, + app_id: 'app-1', + canvas_read_only: false, + active_interaction: null, + conversation_last_seq: 19, + entry_mode: 'fix', + interrupted: false, + phase: 'plan', + run_status: 'waiting_input', + session_id: 'session-1', + state: 'fix.await_approval', + version: 1, +} + +const conversation = Array.from({ length: 20 }, (_, index): ConversationItem => ({ + seq: index, + at_version: 1, + kind: 'notice', + payload: { text: `Conversation message ${index + 1}` }, +})) + +vi.mock('../model-selector', () => ({ + default: () => Model selector, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { setShowDifyBuilderPanel: typeof mocks.closePanel }) => T) => + selector({ setShowDifyBuilderPanel: mocks.closePanel }), +})) + +vi.mock('@/app/components/workflow/hooks-store', () => ({ + useHooksStore: (selector: (state: { configsMap?: undefined }) => T) => + selector({ configsMap: undefined }), +})) + +const nextFrame = () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + +describe('DifyBuilderPanel layout', () => { + // Scrolling and rendered geometry are owned by Chromium and cannot be represented by happy-dom. + it('scrolls conversation actions with the messages while keeping the composer fixed', async () => { + await page.viewport(1280, 720) + const store = createStore() + store.set(difyBuilderSessionViewAtom, sessionView) + store.set(difyBuilderConversationAtom, conversation) + store.set(difyBuilderRuntimeAtom, { + appId: 'app-1', + canEdit: true, + enabled: true, + getCanvasSnapshot: () => ({ nodes: [], edgeCount: 0 }), + onSyncDraft: vi.fn(async () => undefined), + session: { + refresh: vi.fn(async () => true), + getTrace: vi.fn(() => ({ entries: [], truncated: false })), + loadOlderConversation: vi.fn(async () => true), + restore: vi.fn(async () => true), + reset: vi.fn(), + runAction: vi.fn(async () => true), + sendMessage: vi.fn(async () => true), + startBuild: vi.fn(async () => true), + startChecklistFix: vi.fn(async () => true), + startEdit: vi.fn(async () => true), + startFix: vi.fn(async () => true), + updateModel: vi.fn(async () => true), + }, + setShowPanel: mocks.closePanel, + }) + + const screen = await render( + + + + + , + ) + const panel = screen + .getByRole('complementary', { name: 'workflow.difyBuilder.panelTitle' }) + .element() + const action = screen.getByRole('button', { name: 'Approve plan' }).element() + const composer = screen + .getByRole('textbox', { name: 'workflow.difyBuilder.messagePlaceholder' }) + .element() + const scrollContainer = Array.from(panel.querySelectorAll('*')).find((element) => { + const overflowY = getComputedStyle(element).overflowY + return ['auto', 'scroll'].includes(overflowY) && element.scrollHeight > element.clientHeight + }) + if (!scrollContainer) throw new Error('Conversation scroll container was not rendered') + + scrollContainer.scrollTop = scrollContainer.scrollHeight + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + const scrollRectAtBottom = scrollContainer.getBoundingClientRect() + const actionRectAtBottom = action.getBoundingClientRect() + expect(actionRectAtBottom.top).toBeGreaterThanOrEqual(scrollRectAtBottom.top) + expect(actionRectAtBottom.bottom).toBeLessThanOrEqual(scrollRectAtBottom.bottom) + const composerTop = composer.getBoundingClientRect().top + + scrollContainer.scrollTop = 0 + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + expect(action.getBoundingClientRect().top).toBeGreaterThanOrEqual( + scrollContainer.getBoundingClientRect().bottom, + ) + expect(composer.getBoundingClientRect().top).toBe(composerTop) + }) +}) diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx index ddc1fc11aec..1af8bd5d27c 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx @@ -133,14 +133,16 @@ describe('DifyBuilderPanel', () => { } }) - it('keeps actions above a text-only composer and sends chat during an active waiting flow', async () => { + it('keeps actions below the conversation and above a text-only composer', async () => { const user = userEvent.setup() renderPanel() const action = screen.getByRole('button', { name: 'Approve plan' }) + const message = screen.getByText('Fix the workflow') const composer = screen.getByRole('textbox', { name: 'workflow.difyBuilder.messagePlaceholder', }) + expect(message.compareDocumentPosition(action) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(action.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(screen.getByRole('button', { name: 'Model selector' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: /attach/i })).not.toBeInTheDocument() @@ -311,7 +313,7 @@ describe('DifyBuilderPanel', () => { expect(mocks.sendMessage).not.toHaveBeenCalled() }) - it('submits server actions from the fixed action bar', async () => { + it('submits server actions from the conversation action bar', async () => { const user = userEvent.setup() renderPanel() diff --git a/web/app/components/workflow-app/components/dify-builder/panel.tsx b/web/app/components/workflow-app/components/dify-builder/panel.tsx index 6b508426152..d31e9ccec06 100644 --- a/web/app/components/workflow-app/components/dify-builder/panel.tsx +++ b/web/app/components/workflow-app/components/dify-builder/panel.tsx @@ -354,6 +354,16 @@ const DifyBuilderPanel = () => { retryableTurnId={retryableMessage?.turnId} retryingTurnId={retryingTurnId} /> + void handleAction(action)} + /> > ) : ( @@ -398,16 +408,6 @@ const DifyBuilderPanel = () => { )} - void handleAction(action)} - />