diff --git a/frontend/app/[locale]/agents/[agentId]/agent-config.tsx b/frontend/app/[locale]/agents/[agentId]/agent-config.tsx index 7867e9bd0b..323f4228a6 100644 --- a/frontend/app/[locale]/agents/[agentId]/agent-config.tsx +++ b/frontend/app/[locale]/agents/[agentId]/agent-config.tsx @@ -28,6 +28,7 @@ import { useNl2AgentFlow } from "@/contexts/nl2AgentFlow"; import { buildDefaultAgentVersionName } from "@/lib/agentUsageGuide"; import AgentInfo from "./components/agent-info"; +import { AgentEditorSplit } from "./components/agent-editor-split"; import AgentPrmopt from "./components/agent-prompt"; import { AgentSkillCapability, @@ -552,251 +553,261 @@ export default function AgentConfig({ )}
-
+ {debugPanel} + } > - {unavailableReasonLabels.length > 0 && ( - - {`${t("agent.unavailable")}${unavailableReasonLabels.join("、")}`} - + + } + /> + )} +
+ } + open={openSections.display_info} + onOpenChange={(open) => + handleSectionOpenChange("display_info", open) + } + containerRef={displayInfoSectionRef} + > + + + } + open={openSections.role_model} + onOpenChange={(open) => + handleSectionOpenChange("role_model", open) + } + containerRef={roleModelSectionRef} + > + + +
+ + ( + } + open={openSections.knowledge_base} + onOpenChange={(open) => + handleSectionOpenChange("knowledge_base", open) + } + containerRef={knowledgeBaseSectionRef} + headerActions={actions} + > + {content} + + )} + /> + + ( + } + open={openSections.skills} + onOpenChange={(open) => + handleSectionOpenChange("skills", open) + } + containerRef={skillsSectionRef} + headerActions={actions} + > + {content} + + )} + /> + ( + } + open={openSections.tools} + onOpenChange={(open) => + handleSectionOpenChange("tools", open) } - onClick={handleRefreshAvailability} - disabled={isRefreshingAvailability} - loading={isRefreshingAvailability} + containerRef={toolsSectionRef} + headerActions={actions} > - {t("agent.config.refreshAvailability")} - - + {content} + + )} + /> + ( + } + open={openSections.collaborative_agents} + onOpenChange={(open) => + handleSectionOpenChange("collaborative_agents", open) + } + containerRef={collaborativeAgentsSectionRef} + headerActions={actions} + > + {content} + + )} + /> + + } + open={openSections.conversation_guide} + onOpenChange={(open) => + handleSectionOpenChange("conversation_guide", open) } - /> - )} -
+ containerRef={conversationGuideSectionRef} + > + + + } + open={openSections.publish_attributes} + onOpenChange={(open) => + handleSectionOpenChange("publish_attributes", open) + } + containerRef={publishAttributesSectionRef} + > + + } - open={openSections.display_info} + testId="agent-config-card-guardrail" + title={t("agent.config.section.guardrail.title")} + description={t("agent.config.section.guardrail.description")} + icon={} + open={openSections.guardrail} onOpenChange={(open) => - handleSectionOpenChange("display_info", open) + handleSectionOpenChange("guardrail", open) } - containerRef={displayInfoSectionRef} + containerRef={guardrailSectionRef} + headerActions={} > - + } - open={openSections.role_model} + testId="agent-config-card-advanced" + title={t("agent.highFidelity.advancedTitle")} + description="" + icon={} + open={openSections.run_strategy} onOpenChange={(open) => - handleSectionOpenChange("role_model", open) + handleSectionOpenChange("run_strategy", open) } - containerRef={roleModelSectionRef} + containerRef={runStrategySectionRef} > - +
- - ( - } - open={openSections.knowledge_base} - onOpenChange={(open) => - handleSectionOpenChange("knowledge_base", open) - } - containerRef={knowledgeBaseSectionRef} - headerActions={actions} - > - {content} - - )} - /> - - ( - } - open={openSections.skills} - onOpenChange={(open) => - handleSectionOpenChange("skills", open) - } - containerRef={skillsSectionRef} - headerActions={actions} - > - {content} - - )} - /> - ( - } - open={openSections.tools} - onOpenChange={(open) => - handleSectionOpenChange("tools", open) - } - containerRef={toolsSectionRef} - headerActions={actions} - > - {content} - - )} - /> - ( - } - open={openSections.collaborative_agents} - onOpenChange={(open) => - handleSectionOpenChange("collaborative_agents", open) - } - containerRef={collaborativeAgentsSectionRef} - headerActions={actions} - > - {content} - - )} - /> - - } - open={openSections.conversation_guide} - onOpenChange={(open) => - handleSectionOpenChange("conversation_guide", open) - } - containerRef={conversationGuideSectionRef} - > - - - } - open={openSections.publish_attributes} - onOpenChange={(open) => - handleSectionOpenChange("publish_attributes", open) - } - containerRef={publishAttributesSectionRef} - > - - - } - open={openSections.guardrail} - onOpenChange={(open) => - handleSectionOpenChange("guardrail", open) - } - containerRef={guardrailSectionRef} - headerActions={} - > - - - } - open={openSections.run_strategy} - onOpenChange={(open) => - handleSectionOpenChange("run_strategy", open) - } - containerRef={runStrategySectionRef} - > - - -
- - {debugPanel} - +
(null); + const [containerWidth, setContainerWidth] = useState(0); + useEffect(() => { + const container = containerRef.current; + if (!container || contentMin === undefined) return; + const updateWidth = () => setContainerWidth(container.clientWidth); + updateWidth(); + const observer = new ResizeObserver(updateWidth); + observer.observe(container); + return () => observer.disconnect(); + }, [contentMin]); + // Reserve space for the configuration header, while fitting small viewports. + const panelMax = + contentMin !== undefined && containerWidth > 0 + ? Math.min(max, Math.max(min, (1 - contentMin / containerWidth) * 100)) + : max; + const boundedSize = `${Math.min(panelMax, Math.max(min, Number.parseFloat(size)))}%`; + const resizable = visible && !expanded; + const sidePanel = ( + + {panel} + + ); + const contentPanel = ( + + {children} + + ); + + return ( +
+ { + if (!resizable || sizes.length !== 2) return; + if (sizes.some((value) => !Number.isFinite(value) || value < 0)) + return; + const total = sizes[0] + sizes[1]; + if (total <= 0) return; + const percentage = (sizes[side === "start" ? 0 : 1] / total) * 100; + setSize(`${Math.min(panelMax, Math.max(min, percentage))}%`); + }} + > + {side === "start" + ? [sidePanel, contentPanel] + : [contentPanel, sidePanel]} + +
+ ); +} diff --git a/frontend/app/[locale]/agents/[agentId]/page.tsx b/frontend/app/[locale]/agents/[agentId]/page.tsx index 425c223945..7e53d350c8 100644 --- a/frontend/app/[locale]/agents/[agentId]/page.tsx +++ b/frontend/app/[locale]/agents/[agentId]/page.tsx @@ -34,6 +34,7 @@ import { } from "@/lib/agentCreationGuide"; import AgentVersion from "../agent-version"; import AgentDebugPanel from "./components/debug/agent-debug"; +import { AgentEditorSplit } from "./components/agent-editor-split"; import { Nl2AgentChatPanel, type Nl2AgentChatPanelHandle, @@ -381,246 +382,256 @@ function AgentSetupContent({ className="flex min-w-0 min-h-0 flex-1 flex-row" style={{ visibility: isRequestedAgentLoading ? "hidden" : "visible" }} > - setIsGenerationVisible(false)} - className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + setIsGenerationVisible(false)} + className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + > + + + } > - - - } - > -
- {completionSyncFailed ? (
- - {t( - "nl2agent.completion.syncFailed", - "The Agent was generated, but the form could not be refreshed." - )} - - -
- ) : null} - -
-
- - - ) : ( - createElement(getAgentIcon({ agent_id: currentAgentId ?? 0 }), { - size: 16, - }) - ) - } - title={t("agent.highFidelity.pageTab", { - name: - agentInfo?.display_name || - agentInfo?.name || - t("agent.page.panel.config"), - })} - className={"flex-1"} - headerClassName="min-h-16 flex-wrap gap-x-3 gap-y-2 border-[#dfdfdf] px-6 py-3" - leftAction={ - - + + {t( + "nl2agent.completion.syncFailed", + "The Agent was generated, but the form could not be refreshed." + )} + + + + ) : null} + + + } > -
- { - closeDebug(); - setIsPublicationVisible(true); - }} - published={Boolean(agentInfo?.current_version_no)} - actionAreaRef={actionAreaRef} - canManualUnlock={canManualUnlock} - onManualUnlock={handleManualUnlock} - onToggleDebug={() => { - setIsPublicationVisible(false); - if (isDebugVisible) { - closeDebug(); - } else { - setIsDebugVisible(true); - setIsGenerationVisible(false); - } - }} - debugVisible={isDebugVisible} - debugExpanded={isDebugFullscreen || isCompareMode} - debugPanel={ -
- { - if (isDebugFullscreen) { - restoreDebugLayout(); - } else { - panesBeforeFullscreen.current = { - generation: isGenerationVisible, - versions: isShowVersionManagePanel, - }; - setIsGenerationVisible(false); - setIsShowVersionManagePanel(false); - setIsDebugFullscreen(true); - } +
+ + ) : ( + createElement( + getAgentIcon({ agent_id: currentAgentId ?? 0 }), + { + size: 16, + } + ) + ) + } + title={t("agent.highFidelity.pageTab", { + name: + agentInfo?.display_name || + agentInfo?.name || + t("agent.page.panel.config"), + })} + className={"flex-1"} + headerClassName="min-h-16 flex-wrap gap-x-3 gap-y-2 border-[#dfdfdf] px-6 py-3" + leftAction={ +
} - onConfigure={() => { - setIsPublicationVisible(false); - closeDebug(); - setIsGenerationVisible(true); - }} - onOptimizePrompt={() => { - setIsGenerationVisible(true); - nl2AgentChatPanelRef.current?.fillPrompt( - t("nl2agent.optimization.prompt.input") - ); - }} - onPublished={handleAgentPublished} - /> -
- + > +
+ { + closeDebug(); + setIsPublicationVisible(true); + }} + published={Boolean(agentInfo?.current_version_no)} + actionAreaRef={actionAreaRef} + canManualUnlock={canManualUnlock} + onManualUnlock={handleManualUnlock} + onToggleDebug={() => { + setIsPublicationVisible(false); + if (isDebugVisible) { + closeDebug(); + } else { + setIsDebugVisible(true); + setIsGenerationVisible(false); + } + }} + debugVisible={isDebugVisible} + debugExpanded={isDebugFullscreen || isCompareMode} + debugPanel={ +
+ { + if (isDebugFullscreen) { + restoreDebugLayout(); + } else { + panesBeforeFullscreen.current = { + generation: isGenerationVisible, + versions: isShowVersionManagePanel, + }; + setIsGenerationVisible(false); + setIsShowVersionManagePanel(false); + setIsDebugFullscreen(true); + } + }} + /> +
+ } + onConfigure={() => { + setIsPublicationVisible(false); + closeDebug(); + setIsGenerationVisible(true); + }} + onOptimizePrompt={() => { + setIsGenerationVisible(true); + nl2AgentChatPanelRef.current?.fillPrompt( + t("nl2agent.optimization.prompt.input") + ); + }} + onPublished={handleAgentPublished} + /> +
+ - {isShowVersionManagePanel && ( - setIsShowVersionManagePanel(false)} - className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + {isShowVersionManagePanel && ( + setIsShowVersionManagePanel(false)} + className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + > + + + } > - - - } - > -
- -
-
- )} +
+ +
+
+ )} +
+ {isRequestedAgentLoading ? (
({ + resize: (() => {}) as (sizes: number[]) => void, + panels: [] as Array>, +})); + +vi.mock("antd", () => { + const Panel = ({ children, ...props }: { children: ReactNode }) => { + fixture.panels.push(props); + return children; + }; + const Splitter = Object.assign( + ({ + children, + onResize, + }: { + children: ReactNode; + onResize: (sizes: number[]) => void; + }) => { + fixture.resize = onResize; + fixture.panels = []; + return children; + }, + { Panel } + ); + return { Splitter }; +}); + +it.each(["start", "end"] as const)( + "remembers bounded proportions for the %s panel", + (side) => { + render( + Panel
}> +
Config
+ + ); + const index = side === "start" ? 0 : 1; + act(() => fixture.resize(side === "start" ? [400, 600] : [600, 400])); + expect(fixture.panels[index].size).toBe("40%"); + act(() => fixture.resize(side === "start" ? [950, 50] : [50, 950])); + expect(fixture.panels[index].size).toBe("65%"); + act(() => fixture.resize(side === "start" ? [50, 950] : [950, 50])); + expect(fixture.panels[index].size).toBe("25%"); + } +); + +it("reserves 800px for configuration and adapts the drag limit to the viewport", () => { + const clientWidth = vi + .spyOn(HTMLElement.prototype, "clientWidth", "get") + .mockReturnValue(1440); + let resized = () => {}; + const disconnect = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + resized = callback; + } + observe() {} + disconnect = disconnect; + } + ); + try { + const { unmount } = render( + Panel} + > +
Config
+
+ ); + act(() => fixture.resize([900, 540])); + expect(Number.parseFloat(String(fixture.panels[0].size))).toBeCloseTo( + 44.444 + ); + expect(Number.parseFloat(String(fixture.panels[1].min))).toBeCloseTo( + 55.556 + ); + clientWidth.mockReturnValue(1200); + act(() => resized()); + expect(Number.parseFloat(String(fixture.panels[0].size))).toBeCloseTo( + 33.333 + ); + clientWidth.mockReturnValue(900); + act(() => resized()); + expect(fixture.panels[0].size).toBe("20%"); + expect(fixture.panels[1].min).toBe("80%"); + unmount(); + expect(disconnect).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllGlobals(); + } +}); + +it.each([ + [0, 0], + [NaN, 600], + [Infinity, 600], + [-1, 600], +])("ignores invalid measurements %j", (first, second) => { + render( + Panel}> +
Config
+
+ ); + act(() => fixture.resize([first, second])); + expect(fixture.panels[1].size).toBe("48%"); +}); + +it("retains mounted content and restores widths through hidden and expanded modes", () => { + const mounted = vi.fn(); + const unmounted = vi.fn(); + function Content() { + useEffect(() => { + mounted(); + return unmounted; + }, []); + return ; + } + const view = (visible: boolean, expanded = false) => ( + }> +
Config
+
+ ); + const { rerender } = render(view(true)); + const draft = screen.getByRole("textbox"); + act(() => fixture.resize([600, 400])); + for (const mode of ["hidden", "expanded"] as const) { + rerender(view(mode !== "hidden", mode === "expanded")); + expect(fixture.panels[1].size).toBe(mode === "hidden" ? 0 : "100%"); + expect(fixture.panels[1].resizable).toBe(false); + act(() => fixture.resize([100, 900])); + rerender(view(true)); + expect(fixture.panels[1].size).toBe("40%"); + expect(fixture.panels[1].resizable).toBe(true); + expect(screen.getByRole("textbox")).toBe(draft); + } + expect(mounted).toHaveBeenCalledTimes(1); + expect(unmounted).not.toHaveBeenCalled(); +}); diff --git a/test-e2e/cases/AGENT-PANEL-RESIZE-D4-001/case.yaml b/test-e2e/cases/AGENT-PANEL-RESIZE-D4-001/case.yaml new file mode 100644 index 0000000000..5148471af0 --- /dev/null +++ b/test-e2e/cases/AGENT-PANEL-RESIZE-D4-001/case.yaml @@ -0,0 +1,57 @@ +schema_version: '1.0' +stage: D4 +module: AGENT-EDITOR +case: + case_id: AGENT-PANEL-RESIZE-D4-001 + feature_id: AGENT-EDITOR-WORKFLOWS + requirement_ids: [AGENT-PANEL-RESIZE-20261111] + business_rule_ids: [AGENT-EDITOR-RULE-008] + title: Drag Agent editor panels and restore normal widths after mode changes + type: E2E + priority: P1 + status: active + automation: automated + objective: Measure real browser widths after pointer drags and preserve editor state across layout modes. + preconditions: + - Local frontend is available with isolated controlled authorized tenant APIs and an editable Agent fixture. + test_data: + profile: mock + steps: + - order: 1 + action: Enter the Agent detail editor and drag the NL2Agent boundary right then left. + expected: Width changes in each direction and configuration retains at least 800px when space permits; dragging to the maximum at desktop and narrower desktop sizes keeps Publish on the workflow controls row. + - order: 2 + action: Open debug, drag its boundary left then right and beyond both limits, and repeat at a narrower desktop viewport. + expected: Debug changes width in both directions and stays within its proportional limits without squeezing configuration out. + - order: 3 + action: Close and reopen panels, enter and exit debug fullscreen and comparison, then inspect outgoing requests. + expected: Normal widths and mounted content are retained, hidden or expanded splits cannot resize, and no Agent write or model run occurs. + expected_results: + - NL2Agent and debug both respond to pointer drags in the real editor layout. + - Bounds keep both sides usable when resizing and narrowing the viewport. + - Normal widths and content survive layout transitions without business mutations. + forbidden_side_effects: + - No model run, Agent update, publication or persistent server resource creation. + actor: Authorized controlled tenant user + journey: [Open Agent editor, Resize NL2Agent, Open debug, Resize debug, Change layout modes, Verify preserved state] + final_business_result: + - Users allocate editor space to generation, configuration and debugging without losing their work. + failure_evidence: [Measured pane widths, browser assertions and sanitized write counts] + execution: + profiles: + - name: mock + required_services: [] + acceptance: + - id: AC-01 + description: Both panes resize in both directions in the real editor. + steps: [1, 2] + expected_results: [1] + - id: AC-02 + description: Excessive drags and a narrower viewport preserve bounded usable panels. + steps: [2] + expected_results: [2] + - id: AC-03 + description: Layout transitions retain normal sizes and mounted content with no business writes. + steps: [3] + expected_results: [3] + forbidden_side_effects: [1] diff --git a/test-e2e/cases/AGENT-PANEL-RESIZE-D4-001/execution.yaml b/test-e2e/cases/AGENT-PANEL-RESIZE-D4-001/execution.yaml new file mode 100644 index 0000000000..6a5b429d86 --- /dev/null +++ b/test-e2e/cases/AGENT-PANEL-RESIZE-D4-001/execution.yaml @@ -0,0 +1,13 @@ +schema_version: '1.0' +case_id: AGENT-PANEL-RESIZE-D4-001 +preparation: + families: [] +notes: Self-contained controlled browser APIs own all fixtures in the isolated context. Cleanup closes the page; context teardown releases routes and fixture state. No model or server resources are created. +implementations: +- framework: playwright + file: test.spec.ts + selector: AGENT-PANEL-RESIZE-D4-001 +acceptance_bindings: + AC-01: [AGENT-PANEL-RESIZE-D4-001] + AC-02: [AGENT-PANEL-RESIZE-D4-001] + AC-03: [AGENT-PANEL-RESIZE-D4-001] diff --git a/test-e2e/cases/AGENT-PANEL-RESIZE-D4-001/test.spec.ts b/test-e2e/cases/AGENT-PANEL-RESIZE-D4-001/test.spec.ts new file mode 100644 index 0000000000..f7fb4a37f8 --- /dev/null +++ b/test-e2e/cases/AGENT-PANEL-RESIZE-D4-001/test.spec.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { expect, type Locator } from "playwright/test"; +import { join } from "node:path"; +import { journey } from "../../infra/automation/d4/runner/journey"; +import { prepareWorkbench } from "../../infra/automation/d4/fixtures/workbench-controls"; + +journey("AGENT-PANEL-RESIZE-D4-001", async ({ page, contract }) => { + await page.setViewportSize({ width: 1600, height: 1000 }); + await prepareWorkbench(page); + const writes: string[] = []; + const json = (data: unknown) => ({ + contentType: "application/json", + body: JSON.stringify(data), + }); + await page.route("**/api/agent/search_info", (route) => + route.fulfill( + json({ + agent_id: 42, + name: "resize_fixture", + display_name: "Resize fixture", + description: "Isolated resize fixture", + permission: "EDIT", + model_ids: [1], + model_names: ["Fixture Model"], + prompt: "Return the given text", + tools: [], + max_steps: 3, + enabled: true, + is_available: true, + verification_config: { + enabled: false, + step_verification_enabled: true, + final_verification_enabled: true, + llm_verification_enabled: true, + max_final_rounds: 2, + strictness: "balanced", + fail_policy: "repair_then_controlled_summary", + pass_score: 0.75, + critical_events: [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer", + ], + guardrail_config: { + enabled: false, + rules: [], + default_action: "pass", + }, + }, + }), + ), + ); + await page.route("**/api/tool/list**", (route) => route.fulfill(json([]))); + await page.route("**/api/agent/check_name", (route) => + route.fulfill( + json([{ name_conflict: false, display_name_conflict: false }]), + ), + ); + await page.route("**/api/conversation/creator_histories?*", (route) => + route.fulfill(json({ code: 0, data: { items: [], total: 0 } })), + ); + await page.route("**/api/**", async (route) => { + const path = new URL(route.request().url()).pathname; + if ( + /\/agent\/(?:update|update_fields|run|publish|nl2agent\/run)$/.test(path) + ) { + writes.push(path); + await route.fulfill(json({})); + } else await route.fallback(); + }); + contract.deferCleanup(async () => { + await page.close(); + }); + const generation = page.getByTestId("agent-config-conversation-panel"); + const debug = page.getByTestId("agent-debug-pane"); + const config = page.getByTestId("agent-config-scroll-region"); + const generationSplit = page.getByTestId("agent-generation-split"); + const debugSplit = page.getByTestId("agent-debug-split"); + const handle = (split: Locator) => + split.locator( + ':scope > .ant-splitter > .ant-splitter-bar [role="separator"]', + ); + const width = async (target: Locator) => (await target.boundingBox())!.width; + const drag = async (split: Locator, offset: number) => { + const box = (await handle(split).boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + // AntD reserves mouse downs within 300ms for its double-click gesture. + await page.waitForTimeout(350); + await page.mouse.down(); + await page.mouse.move( + box.x + box.width / 2 + offset, + box.y + box.height / 2, + { steps: 12 }, + ); + await page.mouse.up(); + }; + let generationWidth = 0; + let debugWidth = 0; + await contract.precondition( + contract.expectedIds("preconditions")[0], + async () => { + await page.goto(`${process.env.NEXENT_BASE_URL}/zh/agents/42`); + await expect( + page.getByRole("textbox", { name: "* 智能体变量名", exact: true }), + ).toHaveValue("resize_fixture"); + await expect(generation).toBeVisible(); + }, + ); + await contract.step(contract.expectedIds("steps")[0], async () => { + const before = await width(generation); + await drag(generationSplit, 100); + await expect.poll(() => width(generation)).toBeGreaterThan(before + 60); + const grown = await width(generation); + await drag(generationSplit, -60); + await expect.poll(() => width(generation)).toBeLessThan(grown - 30); + const header = page.getByTestId("agent-config-header"); + for (const viewport of [1600, 1200]) { + await page.setViewportSize({ width: viewport, height: 1000 }); + await drag(generationSplit, 2000); + await expect.poll(() => width(debugSplit)).toBeGreaterThanOrEqual(799); + const publish = (await header + .getByRole("button", { name: "发布", exact: true }) + .boundingBox())!; + const modes = (await header.getByRole("group").boundingBox())!; + expect( + Math.abs(publish.y + publish.height / 2 - modes.y - modes.height / 2), + ).toBeLessThan(5); + await page.screenshot({ + path: join(contract.caseDir, `configuration-min-width-${viewport}.png`), + }); + } + await page.setViewportSize({ width: 1600, height: 1000 }); + await drag(generationSplit, -2000); + generationWidth = await width(generation); + await expect(config).toBeVisible(); + }); + await contract.step(contract.expectedIds("steps")[1], async () => { + await page.getByRole("button", { name: "智能体调试", exact: true }).click(); + await expect(debug).toBeVisible(); + const before = await width(debug); + await drag(debugSplit, -80); + await expect.poll(() => width(debug)).toBeGreaterThan(before + 40); + const grown = await width(debug); + await drag(debugSplit, 60); + await expect.poll(() => width(debug)).toBeLessThan(grown - 30); + for (const viewport of [1600, 1100]) { + await page.setViewportSize({ width: viewport, height: 1000 }); + for (const [offset, expected] of [ + [-2000, 0.65], + [2000, 0.25], + ] as const) { + await drag(debugSplit, offset); + await expect + .poll(async () => (await width(debug)) / (await width(debugSplit))) + .toBeCloseTo(expected, 1); + expect(await width(config)).toBeGreaterThan(100); + } + } + await page.setViewportSize({ width: 1600, height: 1000 }); + await drag(debugSplit, -100); + debugWidth = await width(debug); + }); + await contract.step(contract.expectedIds("steps")[2], async () => { + await debug.evaluate((element) => + element.setAttribute("data-retained", "debug"), + ); + await page.getByRole("button", { name: "全屏调试", exact: true }).click(); + await expect(handle(debugSplit)).toHaveAttribute("aria-disabled", "true"); + await page + .getByRole("button", { name: "还原调试布局", exact: true }) + .click(); + await expect.poll(() => width(debug)).toBeCloseTo(debugWidth, 0); + await page + .getByRole("button", { name: "切换对比模式", exact: true }) + .click(); + await expect(handle(debugSplit)).toHaveAttribute("aria-disabled", "true"); + await page + .getByRole("button", { name: "切换对比模式", exact: true }) + .click(); + await expect.poll(() => width(debug)).toBeCloseTo(debugWidth, 0); + await page.getByRole("button", { name: "智能体调试", exact: true }).click(); + await expect(handle(debugSplit)).toHaveAttribute("aria-disabled", "true"); + await page.getByRole("button", { name: "智能体调试", exact: true }).click(); + await expect.poll(() => width(debug)).toBeCloseTo(debugWidth, 0); + await expect(debug).toHaveAttribute("data-retained", "debug"); + await page.getByRole("button", { name: "智能体调试", exact: true }).click(); + await page.getByRole("button", { name: "智能生成", exact: true }).click(); + await expect.poll(() => width(generation)).toBeCloseTo(generationWidth, 0); + await generation.evaluate((element) => + element.setAttribute("data-retained", "generation"), + ); + await page.getByRole("button", { name: "智能生成", exact: true }).click(); + await expect(handle(generationSplit)).toHaveAttribute( + "aria-disabled", + "true", + ); + await page.getByRole("button", { name: "智能生成", exact: true }).click(); + await expect(generation).toHaveAttribute("data-retained", "generation"); + await expect.poll(() => width(generation)).toBeCloseTo(generationWidth, 0); + expect(writes).toEqual([]); + await page.screenshot({ + path: join(contract.caseDir, "resized-editor.png"), + }); + }); + for (const id of contract.expectedIds("assertions")) { + await contract.assertion(id, async () => { + expect(generationWidth).toBeGreaterThan(0); + expect(debugWidth).toBeGreaterThan(0); + await expect(config).toBeVisible(); + expect(writes).toEqual([]); + }); + } +}); diff --git a/test-e2e/changes/requirements/AGENT-PANEL-RESIZE-20261111.yaml b/test-e2e/changes/requirements/AGENT-PANEL-RESIZE-20261111.yaml new file mode 100644 index 0000000000..27ba2da3c6 --- /dev/null +++ b/test-e2e/changes/requirements/AGENT-PANEL-RESIZE-20261111.yaml @@ -0,0 +1,31 @@ +schema_version: '1.0' +change: + change_id: AGENT-PANEL-RESIZE-20261111 + change_type: requirement + requirement_id: AGENT-PANEL-RESIZE-20261111 + title: Resize NL2Agent and debug panels in the Agent editor + description: Drag native split boundaries without changing Agent data or resetting conversations. + affected_features: + existing: [AGENT-EDITOR-WORKFLOWS] + added: [] + modified: [AGENT-EDITOR-WORKFLOWS] + retired: [] + affected_cases: + existing: [] + added: [AGENT-PANEL-RESIZE-D4-001] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Save valid dragged proportions and constrain panel sizes. + - Ignore zero, non-finite or hidden/expanded resize callbacks. + - Restore normal widths and retain mounted children after visibility and expanded transitions. + - Reserve configuration header space while adapting the maximum NL2Agent width to the viewport, with observer cleanup on unmount. + test_selectors: + - frontend/tests/workbench/AgentEditorSplit.test.tsx + - frontend/tests/workbench/AgentDebugHeader.test.tsx + reason: Frontend local presentation state needs UT; no Python/API/runtime changes. Browser layout needs independent pointer-drag evidence, not another D1. + notes: | + SPEC: docs/Developing/ux-agent-workbench-layout/changes/ux-editor-panel-resizing/ in the document repository. + Responsibility inherits agent_config. The browser context owns controlled APIs and fixtures and releases them on teardown; no server data or shared preparation factories. diff --git a/test-e2e/features/AGENT-EDITOR-WORKFLOWS/cases.md b/test-e2e/features/AGENT-EDITOR-WORKFLOWS/cases.md index ddd0394e32..736f4d7197 100644 --- a/test-e2e/features/AGENT-EDITOR-WORKFLOWS/cases.md +++ b/test-e2e/features/AGENT-EDITOR-WORKFLOWS/cases.md @@ -9,3 +9,4 @@ - D4 [AGENT-EDITOR-D4-005](../../cases/AGENT-EDITOR-D4-005/case.yaml) · active - D4 [AGENT-EDITOR-D4-006](../../cases/AGENT-EDITOR-D4-006/case.yaml) · active - D4 [AGENT-EDITOR-D4-007](../../cases/AGENT-EDITOR-D4-007/case.yaml) · active +- D4 [AGENT-PANEL-RESIZE-D4-001](../../cases/AGENT-PANEL-RESIZE-D4-001/case.yaml) · active diff --git a/test-e2e/features/AGENT-EDITOR-WORKFLOWS/feature.yaml b/test-e2e/features/AGENT-EDITOR-WORKFLOWS/feature.yaml index 010684ee8b..455d29ef02 100644 --- a/test-e2e/features/AGENT-EDITOR-WORKFLOWS/feature.yaml +++ b/test-e2e/features/AGENT-EDITOR-WORKFLOWS/feature.yaml @@ -33,6 +33,12 @@ feature: - rule_id: AGENT-EDITOR-RULE-007 description: Selected MCP tools appear as one row per named service, and edits from the group persist individual tool IDs without replacing unrelated bindings. + - rule_id: AGENT-EDITOR-RULE-008 + description: NL2Agent and debug panel boundaries can be dragged within proportional + bounds, preserving normal widths and mounted conversations through visibility, + fullscreen and comparison transitions without writing Agent configuration. + NL2Agent resizing reserves at least 800px for configuration when space permits, + keeping the publication action on the same row as the workflow controls. required_test_stages: - D4 source: @@ -44,4 +50,5 @@ feature: - AGENT-EDITOR-REQ-005 - AGENT-EDITOR-REQ-006 - AGENT-EDITOR-REQ-007 + - AGENT-PANEL-RESIZE-20261111 spec_path: ../nexent-doc/docs/Developing/agt-pr4058-test-delivery/design.md