diff --git a/application/single_app/config.py b/application/single_app/config.py
index 90a366090..dda8fe66a 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.326"
+VERSION = "0.261.327"
IS_DEVELOPMENT = is_development_env_enabled()
# Opt-out for deployments where App Service Easy Auth is active but the platform
diff --git a/application/v2_ui/src/components/chat/Composer.tsx b/application/v2_ui/src/components/chat/Composer.tsx
index 19ae13a2e..68f91dde0 100644
--- a/application/v2_ui/src/components/chat/Composer.tsx
+++ b/application/v2_ui/src/components/chat/Composer.tsx
@@ -108,6 +108,7 @@ import {
type ReasoningEffortSettings,
} from '../../lib/reasoning';
import { Dropdown, type DropdownOption } from '../ui/Dropdown';
+import { DropdownScope } from '../ui/DropdownScope';
import { suggestPromptName } from '../../lib/promptSlash';
import { readPromptParam, readPromptScope } from '../../lib/conversationUrl';
import { createPrompt } from '../../lib/workspaceApi';
@@ -1808,8 +1809,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
? `Orchestrate ยท ${effectiveApprovalMode}`
: activeAgentLabel || modelOptions.find((model) => model.value === options.modelDeployment)?.label || 'Tools'}
-
+
{orchestrationAvailable && (
0 && (
0 && (
0 && (
)}
-
+
{features.enable_speech_to_text_input && (
{
+ if (floating && scopeSetter) {
+ scopeSetter((current) => next ? id : current === id ? null : current);
+ } else {
+ setLocalOpen(next);
+ }
+ }, [floating, scopeSetter, id]);
// These pickers sit in the composer at the bottom of the viewport, where a menu that
// always drops downward runs off the bottom of the window. Placement is measured on
// open rather than fixed, so the same component works wherever it is used.
const [placement, setPlacement] = useState<'down' | 'up'>('down');
const [maxHeight, setMaxHeight] = useState(MENU_MAX_HEIGHT);
const containerRef = useRef(null);
+ const triggerRef = useRef(null);
+ const panelRef = useRef(null);
+ const [position, setPosition] = useState | null>(null);
const selected = options.find((option) => option.value === value);
// What the trigger reads as. An overridden selection is still `selected` for the menu's
@@ -77,12 +99,30 @@ export function Dropdown({
const triggerLabel = inactive ? placeholder : (selected?.label ?? placeholder);
const measure = useCallback(() => {
- const element = containerRef.current;
+ const element = triggerRef.current;
if (!element) {
return;
}
const rect = element.getBoundingClientRect();
+ if (floating) {
+ const viewport = window.visualViewport;
+ const visibleBounds = boundary?.current?.getBoundingClientRect();
+ if (!element.isConnected || !element.getClientRects().length || (visibleBounds && (
+ rect.bottom <= visibleBounds.top || rect.top >= visibleBounds.bottom
+ || rect.right <= visibleBounds.left || rect.left >= visibleBounds.right
+ ))) {
+ setOpen(false);
+ return;
+ }
+ setPosition(dropdownPlacement(rect, {
+ left: viewport?.offsetLeft ?? 0,
+ top: viewport?.offsetTop ?? 0,
+ width: viewport?.width ?? window.innerWidth,
+ height: viewport?.height ?? window.innerHeight,
+ }, align));
+ return;
+ }
const spaceBelow = window.innerHeight - rect.bottom - MENU_GAP - VIEWPORT_MARGIN;
const spaceAbove = rect.top - MENU_GAP - VIEWPORT_MARGIN;
@@ -94,53 +134,211 @@ export function Dropdown({
setPlacement('up');
setMaxHeight(Math.max(MIN_USABLE_HEIGHT, Math.min(MENU_MAX_HEIGHT, spaceAbove)));
- }, []);
+ }, [floating, align, setOpen, boundary]);
+
+ useEffect(() => {
+ if (disabled) {
+ setLocalOpen(false);
+ if (scope?.activeId === id) scope.setActiveId(null);
+ }
+ }, [disabled, scope, id]);
+
+ useEffect(() => () => {
+ // Do not close a replacement picker during the old trigger's cleanup.
+ if (floating && scopeSetter) {
+ scopeSetter((current) => current === id ? null : current);
+ }
+ }, [floating, scopeSetter, id]);
+
+ useLayoutEffect(() => {
+ if (!open || !floating) return;
+ measure();
+ const panel = panelRef.current;
+ const initial = panel?.querySelector('[role="option"][aria-selected="true"]')
+ ?? panel?.querySelector('[role="option"], button');
+ (initial ?? panel)?.focus({ preventScroll: true });
+ initial?.scrollIntoView({ block: 'nearest' });
+ }, [open, floating, measure]);
useEffect(() => {
if (!open) {
return;
}
- const onPointerDown = (event: MouseEvent) => {
- if (!containerRef.current?.contains(event.target as Node)) {
+ const onPointerDown = (event: PointerEvent) => {
+ if (!containerRef.current?.contains(event.target as Node)
+ && !panelRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
+ event.preventDefault();
setOpen(false);
+ triggerRef.current?.focus({ preventScroll: true });
}
};
+ const onFocus = (event: FocusEvent) => {
+ if (floating && !containerRef.current?.contains(event.target as Node)
+ && !panelRef.current?.contains(event.target as Node)) setOpen(false);
+ };
- document.addEventListener('mousedown', onPointerDown);
+ document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
+ document.addEventListener('focusin', onFocus);
window.addEventListener('resize', measure);
// Capture, so the menu keeps up with any scrolling ancestor and not just the page.
window.addEventListener('scroll', measure, true);
+ window.visualViewport?.addEventListener('resize', measure);
+ window.visualViewport?.addEventListener('scroll', measure);
+ const observer = floating ? new ResizeObserver(measure) : null;
+ if (triggerRef.current) observer?.observe(triggerRef.current);
return () => {
- document.removeEventListener('mousedown', onPointerDown);
+ document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
+ document.removeEventListener('focusin', onFocus);
window.removeEventListener('resize', measure);
window.removeEventListener('scroll', measure, true);
+ window.visualViewport?.removeEventListener('resize', measure);
+ window.visualViewport?.removeEventListener('scroll', measure);
+ observer?.disconnect();
};
- }, [open, measure]);
+ }, [open, measure, floating, setOpen]);
// Options arrive pre-grouped by scope (personal / group / public), so headings are
// emitted whenever the group changes rather than by re-sorting the list.
let lastGroup: string | undefined;
+ const menu = open && (
+ {
+ if (!floating) return;
+ const buttons = Array.from(panelRef.current?.querySelectorAll
('button') ?? []);
+ const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
+ let next: number | undefined;
+ if (event.key === 'ArrowDown') next = (index + 1) % buttons.length;
+ if (event.key === 'ArrowUp') next = (index - 1 + buttons.length) % buttons.length;
+ if (event.key === 'Home') next = 0;
+ if (event.key === 'End') next = buttons.length - 1;
+ if (next !== undefined && buttons.length) {
+ event.preventDefault();
+ buttons[next].focus({ preventScroll: true });
+ buttons[next].scrollIntoView({ block: 'nearest' });
+ }
+ if (event.key === 'Tab') {
+ setOpen(false);
+ triggerRef.current?.focus({ preventScroll: true });
+ }
+ }}
+ >
+ {clearable && value !== undefined && (
+
+ )}
+
+
+ {options.length === 0 && (
+
+ Nothing available
+
+ )}
+
+ {options.map((option) => {
+ const showGroupHeading = option.group && option.group !== lastGroup;
+ lastGroup = option.group;
+ const isSelected = option.value === value;
+
+ return (
+
+ {showGroupHeading && (
+
+ {option.group}
+
+ )}
+
+
+ );
+ })}
+
+ {hint && (
+
+ {hint}
+
+ )}
+
+ );
+
return (
-
- {open && (
-
-
- {clearable && value !== undefined && (
-
- )}
-
- {options.length === 0 && (
-
- Nothing available
-
- )}
-
- {options.map((option) => {
- const showGroupHeading = option.group && option.group !== lastGroup;
- lastGroup = option.group;
- const isSelected = option.value === value;
-
- return (
-
- {showGroupHeading && (
-
- {option.group}
-
- )}
-
-
- );
- })}
-
- {hint && (
-
- {hint}
-
- )}
-
- )}
+ {floating ? createPortal(menu, document.body) : menu}
);
}
diff --git a/application/v2_ui/src/components/ui/DropdownScope.tsx b/application/v2_ui/src/components/ui/DropdownScope.tsx
new file mode 100644
index 000000000..a4940e5a0
--- /dev/null
+++ b/application/v2_ui/src/components/ui/DropdownScope.tsx
@@ -0,0 +1,39 @@
+// DropdownScope.tsx
+
+import { createContext, useEffect, useRef, useState, type Dispatch, type ReactNode, type RefObject, type SetStateAction } from 'react';
+
+export const DropdownScopeContext = createContext<{
+ activeId: string | null;
+ setActiveId: Dispatch>;
+ enabled: boolean;
+ boundary: RefObject | null;
+} | null>(null);
+
+/** Composer disclosures own picker lifetime without remounting their controls. */
+export function DropdownScope({ children, mobileOpen, closeKey }: {
+ children: ReactNode;
+ mobileOpen: boolean;
+ closeKey: string | null;
+}) {
+ const [desktop, setDesktop] = useState(() => window.matchMedia('(min-width: 768px)').matches);
+ const [activeId, setActiveId] = useState(null);
+ const boundary = useRef(null);
+ const enabled = desktop || mobileOpen;
+ useEffect(() => {
+ const media = window.matchMedia('(min-width: 768px)');
+ const update = () => setDesktop(media.matches);
+ media.addEventListener('change', update);
+ return () => media.removeEventListener('change', update);
+ }, []);
+ useEffect(() => {
+ setActiveId(null);
+ }, [enabled, closeKey]);
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/ui/dropdownPlacement.ts b/application/v2_ui/src/components/ui/dropdownPlacement.ts
new file mode 100644
index 000000000..70cfa9dd3
--- /dev/null
+++ b/application/v2_ui/src/components/ui/dropdownPlacement.ts
@@ -0,0 +1,33 @@
+// dropdownPlacement.ts
+
+export interface PickerViewport {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
+/** Fixed coordinates use the visual viewport's offset in the layout viewport. */
+export function dropdownPlacement(
+ trigger: { left: number; right: number; top: number; bottom: number },
+ viewport: PickerViewport,
+ align: 'left' | 'right',
+) {
+ const margin = Math.min(12, viewport.width / 2, viewport.height / 2);
+ const gap = 8;
+ const width = Math.max(0, Math.min(288, viewport.width - margin * 2));
+ const left = Math.max(viewport.left + margin, Math.min(
+ align === 'right' ? trigger.right - width : trigger.left,
+ viewport.left + viewport.width - margin - width,
+ ));
+ const floor = viewport.top + viewport.height - margin;
+ const ceiling = viewport.top + margin;
+ const below = Math.max(0, floor - trigger.bottom - gap);
+ const above = Math.max(0, trigger.top - gap - ceiling);
+ const up = below < 320 && above > below;
+ const maxHeight = Math.min(320, up ? above : below, Math.max(0, floor - ceiling));
+ const anchor = up
+ ? Math.max(ceiling, Math.min(floor, trigger.top - gap))
+ : Math.max(ceiling, Math.min(floor, trigger.bottom + gap));
+ return { left, width, maxHeight, top: anchor, up };
+}
diff --git a/application/v2_ui/src/styles/theme.css b/application/v2_ui/src/styles/theme.css
index 958f76db0..4d80a24aa 100644
--- a/application/v2_ui/src/styles/theme.css
+++ b/application/v2_ui/src/styles/theme.css
@@ -68,6 +68,7 @@
.chat-header-actions button,
.composer-primary-actions button,
.composer-secondary-tools button,
+ .composer-picker-panel button,
.explorer-command-bar button,
.explorer-command-bar select,
.explorer-command-bar input,
@@ -76,6 +77,11 @@
min-height: 44px;
}
+ .composer-picker-panel {
+ overscroll-behavior: contain;
+ overflow-wrap: anywhere;
+ }
+
.mobile-navigation-controls button,
#primary-navigation button,
.chat-header-actions button,
diff --git a/docs/explanation/fixes/V2_COMPOSER_PICKER_OVERLAYS_FIX.md b/docs/explanation/fixes/V2_COMPOSER_PICKER_OVERLAYS_FIX.md
new file mode 100644
index 000000000..841374cfe
--- /dev/null
+++ b/docs/explanation/fixes/V2_COMPOSER_PICKER_OVERLAYS_FIX.md
@@ -0,0 +1,85 @@
+# V2 composer picker clipping fix (0.261.327)
+
+Fixed/Implemented in version: **0.261.327**, recorded in
+`application/single_app/config.py`.
+
+## Issue and root cause
+
+Expanded mobile Chat tools deliberately scrolls within `30dvh`. The shared
+Dropdown rendered its menu absolutely inside that overflow boundary.
+Positioning upward and raising its stacking order did not stop the boundary
+from cutting off Agent, Prompt, and other options. A browser regression using
+the original Dropdown reproduced an Agent option above the tools rectangle
+whose center could not be hit-tested.
+
+## Technical changes
+
+`Dropdown.tsx` has an explicit floating presentation, enabled for all six
+composer dropdown variants in `Composer.tsx`. It renders a fixed portal into
+the document body, measures the trigger, and delegates bounds to
+`dropdownPlacement.ts`. Other Dropdown instances retain inline positioning.
+The helper flips toward available space and clamps width, horizontal position,
+and height without imposing a minimum height greater than the available space.
+It accounts for visual viewport offsets and dimensions, including keyboard or
+zoom viewport changes when the browser exposes them.
+
+`DropdownScope.tsx` owns the existing scrolling tools container and coordinates
+one active picker. Closing the mobile disclosure, changing conversation,
+unmounting or disabling a trigger, collapsing Manual controls, and scrolling a
+trigger out of the tools container removes its portal. Window/visual viewport
+resize and capture-phase scrolling reposition open menus; listeners and the
+trigger resize observer are cleaned up.
+
+Portal pointer events count as inside the menu. Outside pointer dismissal does
+not cancel the underlying action. Escape and selection return focus to the
+trigger; arrow keys and Home/End traverse choices, and Tab exits. Options retain
+listbox selection semantics and checkmarks; Clear selection remains separate
+from the options. The menu itself scrolls, keeping clear and hint content
+reachable even in short viewports. `theme.css` supplies portal-specific mobile
+touch targets because the tools container is no longer its ancestor.
+
+The portal uses layer 40, below the existing layer-50 modal. The real document
+picker is mounted through ComposerEditor outside the tools overflow container
+and does not need the same change. No new API, permission, setting, dependency,
+or deployer change is involved.
+
+## Validation
+
+`ui_tests/test_v2_composer_picker_overlays.py` runs production Composer,
+Dropdown, stores, and CSS with strict synthetic HTTP. It covers the six picker
+variants, grouped 36-entry Agent/Prompt catalogs, first/last choices, selected
+checkmarks, clear, disabled/empty states, switching menus, outside dismissal,
+keyboard navigation, disclosure/conversation lifecycle, scroll/resize, dialog
+entry, and saved approval/model choices. It measures actual viewport geometry
+and option center hit-testing outside the tools boundary at phone widths
+320/360/393/430, short landscape, desktop, both themes, and enlarged text.
+
+`functional_tests/test_v2_composer_picker_placement.py` executes the real
+TypeScript helper with Node assertions over normal, tiny, offset, and short
+viewports. Its operations and assertions remain active under optimized Python.
+The original dropdown functional coverage remains in
+`functional_tests/test_v2_dropdown_placement.py`.
+
+Related mobile navigation/header/chat, reasoning, approval/model persistence,
+and prompt browser tests exercise the existing workflows. Shared test helpers
+open Chat tools before reaching mobile pickers; prompt status checks identify
+their own status instead of the shell's unrelated live region.
+
+The final picker/mobile/placement runner passed **98 tests**; the independent
+reasoning, approval/model, and prompt runner passed **123 tests**. These run
+separately because the legacy tests own a synchronous Playwright connection
+while the mobile fixtures use pytest's connection. Prebuilding the shared
+harness avoids competing fixture builds. The original-Dropdown reproduction
+failed the actual Agent-option hit-test as expected; the fixed implementation
+passed.
+
+Both TypeScript/Vite builds passed, as did the optimized placement probe.
+Documentation surface coverage passed **7/7**, documentation site quality
+**6/6**, and V2 local-asset coverage **4/4**. Full-file picker and added-line
+application XSS checks, whitespace checks, and the scoped UI mechanical
+detector reported no findings. Vite retains its existing large-bundle warning.
+
+Production and harness TypeScript/Vite builds serve only local assets.
+Phone/desktop screenshots are test artifacts, not committed application data.
+Actual iPhone Safari keyboard, browser chrome, and safe-area behavior remain a
+manual physical-device check; Chromium emulation does not certify those cases.
diff --git a/docs/guides/v2-mobile.md b/docs/guides/v2-mobile.md
index 6a53f819f..482fbdadb 100644
--- a/docs/guides/v2-mobile.md
+++ b/docs/guides/v2-mobile.md
@@ -4,7 +4,7 @@ title: "Use V2 on a phone"
description: "Find navigation, chat tools, and workspace documents without permanent sidebars narrowing your phone screen."
section: "Guides"
audience: user
-version: "0.261.325"
+version: "0.261.327"
---
## Find your work
@@ -54,6 +54,19 @@ The composer keeps attachment, voice when enabled, and Send/Stop together.
controls. Closing this area does not clear your choices. When orchestration is
active, its approval mode appears in the collapsed summary.
+Fixed/Implemented in version: **0.261.327**, recorded in
+`application/single_app/config.py`.
+
+Model, Agent, Prompt, Reasoning, Approval, and Orchestration model choices open
+in floating menus anchored to their buttons, outside the scrolling tools area.
+The menus open above or below according to available space, stay within the
+visible viewport, and scroll long catalogs without cutting off choices. This
+does not change the composer layout or replace the menu with a bottom sheet.
+Tap an option to select it or tap outside to dismiss. Escape returns keyboard
+focus to the opener; arrow keys and Home/End move through choices. Only one
+picker opens at a time. Closing Chat tools or Manual controls closes their
+picker without clearing the saved selection.
+
## Browse workspace documents
**Workspace sections** names your current section and opens the workspace's
diff --git a/functional_tests/test_v2_composer_picker_placement.py b/functional_tests/test_v2_composer_picker_placement.py
new file mode 100644
index 000000000..762b72807
--- /dev/null
+++ b/functional_tests/test_v2_composer_picker_placement.py
@@ -0,0 +1,69 @@
+# test_v2_composer_picker_placement.py
+"""
+Functional test for viewport-bounded floating composer pickers.
+Version: 0.261.327
+Implemented in: 0.261.327
+
+Executes the production TypeScript placement helper through the installed esbuild.
+Node's explicit checks run identically under normal and optimized Python.
+"""
+
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+UI = ROOT / "application" / "v2_ui"
+
+
+def test_floating_placement_bounds_and_visual_viewport_offsets():
+ with tempfile.TemporaryDirectory(prefix="simplechat-picker-") as directory:
+ bundle = Path(directory) / "placement.cjs"
+ subprocess.run([
+ "node", str(UI / "node_modules" / "esbuild" / "bin" / "esbuild"),
+ str(UI / "src" / "components" / "ui" / "dropdownPlacement.ts"),
+ "--bundle", "--platform=node", "--format=cjs", f"--outfile={bundle}", "--log-level=error",
+ ], check=True, capture_output=True, text=True)
+ probe = r"""
+const assert = require('node:assert/strict');
+const {dropdownPlacement: place} = require(process.argv[1]);
+const top = place({left: 20, right: 100, top: 20, bottom: 64},
+ {left: 0, top: 0, width: 393, height: 852}, 'left');
+assert.equal(top.up, false);
+assert.equal(top.top, 72);
+const bottom = place({left: 280, right: 360, top: 700, bottom: 744},
+ {left: 0, top: 0, width: 393, height: 852}, 'right');
+assert.equal(bottom.up, true);
+assert.equal(bottom.top, 692);
+assert.equal(bottom.maxHeight, 320);
+for (const width of [16, 24, 96, 320, 360, 393, 430, 1280]) {
+ for (const height of [16, 24, 96, 140, 320, 393, 740, 852]) {
+ for (const left of [0, 29]) {
+ for (const top of [0, 83]) {
+ const viewport = {left, top, width, height};
+ for (const fraction of [0, .1, .5, .9, 1]) {
+ const trigger = {left: left + width * fraction, right: left + width * fraction + 90,
+ top: top + height * fraction, bottom: top + height * fraction + 44};
+ for (const align of ['left', 'right']) {
+ const p = place(trigger, viewport, align);
+ const margin = Math.min(12, width / 2, height / 2);
+ assert.ok(p.width >= 0 && p.width <= 288);
+ assert.ok(p.left >= left + margin);
+ assert.ok(p.left + p.width <= left + width - margin + .001);
+ assert.ok(p.maxHeight >= 0 && p.maxHeight <= 320);
+ const ceiling = p.up ? p.top - p.maxHeight : p.top;
+ const floor = p.up ? p.top : p.top + p.maxHeight;
+ assert.ok(ceiling >= top + margin - .001, JSON.stringify({p, viewport, trigger}));
+ assert.ok(floor <= top + height - margin + .001, JSON.stringify({p, viewport, trigger}));
+ }
+ }
+ }
+ }
+ }
+}
+"""
+ subprocess.run(["node", "-e", probe, str(bundle)], check=True, capture_output=True, text=True)
+
+
+if __name__ == "__main__":
+ test_floating_placement_bounds_and_visual_viewport_offsets()
diff --git a/functional_tests/test_v2_dropdown_placement.py b/functional_tests/test_v2_dropdown_placement.py
index 93715c26d..896174b2c 100644
--- a/functional_tests/test_v2_dropdown_placement.py
+++ b/functional_tests/test_v2_dropdown_placement.py
@@ -1,7 +1,7 @@
-#!/usr/bin/env python3
+# test_v2_dropdown_placement.py
"""
Functional test for V2 composer dropdown placement.
-Version: 0.261.011
+Version: 0.261.327
Implemented in: 0.261.011
The V2 composer is anchored to the bottom of the viewport, so its Model, Agent,
@@ -107,7 +107,7 @@ def test_height_is_clamped_to_available_space():
source = read(DROPDOWN)
assert "maxHeight" in source, "The menu does not constrain its own height."
- assert "style={{ maxHeight }}" in source, (
+ assert "} : { maxHeight }}" in source, (
"The measured height is never applied to the menu element."
)
assert "overflow-y-auto" in source, (
diff --git a/ui_tests/composer_helpers.py b/ui_tests/composer_helpers.py
new file mode 100644
index 000000000..af9d7369d
--- /dev/null
+++ b/ui_tests/composer_helpers.py
@@ -0,0 +1,9 @@
+# composer_helpers.py
+"""Shared production composer disclosure helpers (version 0.261.327)."""
+
+
+def open_composer_tools(page):
+ """Reach the mobile tools before exercising a nested picker."""
+ tools = page.get_by_role("button", name="Chat tools", exact=True)
+ if tools.is_visible() and tools.get_attribute("aria-expanded") == "false":
+ tools.click()
diff --git a/ui_tests/fixtures/orchestration/harness_entry.tsx b/ui_tests/fixtures/orchestration/harness_entry.tsx
index c7148a40c..e1ce0a734 100644
--- a/ui_tests/fixtures/orchestration/harness_entry.tsx
+++ b/ui_tests/fixtures/orchestration/harness_entry.tsx
@@ -41,6 +41,7 @@ import { ComposerEditor } from '../../../application/v2_ui/src/components/chat/C
import { DocumentExplorer } from '../../../application/v2_ui/src/components/documents/DocumentExplorer';
import { ParticipantsPanel } from '../../../application/v2_ui/src/components/chat/ParticipantsPanel';
import { Toaster } from '../../../application/v2_ui/src/components/ui/Toaster';
+import { Dropdown } from '../../../application/v2_ui/src/components/ui/Dropdown';
import { AppShell } from '../../../application/v2_ui/src/components/layout/AppShell';
import { ChatPage } from '../../../application/v2_ui/src/pages/ChatPage';
import { HomePage } from '../../../application/v2_ui/src/pages/HomePage';
@@ -199,6 +200,7 @@ type ComponentName =
| 'ChatExperience'
| 'HomeExperience'
| 'HeaderExperience'
+ | 'Dropdown'
| 'ApprovalPreferenceWorkflow'
| 'PlannerModelWorkflow'
| 'PlanEditorExperience'
@@ -226,6 +228,7 @@ const components: Record ReactElement | null> = {
ChatExperience,
HomeExperience,
HeaderExperience,
+ Dropdown,
ApprovalPreferenceWorkflow,
PlannerModelWorkflow,
PlanEditorExperience,
diff --git a/ui_tests/test_v2_composer_picker_overlays.py b/ui_tests/test_v2_composer_picker_overlays.py
new file mode 100644
index 000000000..245b3f74d
--- /dev/null
+++ b/ui_tests/test_v2_composer_picker_overlays.py
@@ -0,0 +1,340 @@
+# test_v2_composer_picker_overlays.py
+"""
+Real-component regressions for anchored composer picker overlays.
+Version: 0.261.327
+Implemented in: 0.261.327
+
+Production Composer, Dropdown, stores and theme CSS; only HTTP is synthetic.
+Local Chromium by default, Azure Playwright through the shared connection fixture.
+Real Safari keyboard and safe-area behavior require a physical device.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import expect
+
+from test_v2_sidebar_conversation_scroll import mount
+from test_v2_orchestration_approval_persistence import ApprovalApi, mount as mount_approval
+from test_v2_orchestration_model_picker import configure as configure_models, WRITER, SUMMARY
+from test_v2_chat_context_selection import ContextApi
+
+pytest_plugins = (
+ "test_v2_sidebar_conversation_scroll",
+ "test_v2_orchestration_plan_editor",
+ "test_v2_orchestration_approval_persistence",
+)
+pytestmark = pytest.mark.ui
+ARTIFACTS = Path(__file__).parent / "artifacts" / "composer-picker-overlays"
+
+
+@pytest.fixture
+def picker_approval_ui(editor_browser, approval_assets):
+ # Reuse the running Playwright connection rather than nesting a second sync loop.
+ api = ApprovalApi(approval_assets)
+ contexts = []
+ errors = []
+
+ def make_page(width):
+ context = editor_browser.new_context(viewport={"width": width, "height": 900})
+ contexts.append(context)
+ page = context.new_page()
+ page.route("**/*", api.handle)
+ page.on("pageerror", lambda error: errors.append(str(error)))
+ page.on("console", lambda message: errors.append(message.text) if message.type == "error" else None)
+ return page
+
+ yield make_page, api
+ for context in contexts:
+ context.close()
+ assert not api.unexpected, api.unexpected
+ assert not errors, errors
+
+
+def populate(page):
+ page.evaluate("""() => {
+ const H = window.OrchHarness;
+ const models = [{
+ selection_key: 'global::test:writer', model_id: 'writer', endpoint_id: 'test',
+ deployment_name: 'writer', provider: 'aoai', display_name: 'Writer',
+ reasoning_capabilities: {status: 'supported', efforts: ['low', 'medium', 'high'], default_effort: 'low'},
+ }, {selection_key: 'global::test:summary', model_id: 'summary', endpoint_id: 'test',
+ deployment_name: 'summary', provider: 'aoai', display_name: 'Summary'}];
+ const grouped = (kind) => Array.from({length: 36}, (_, i) => ({
+ id: `${kind}-${i}`, name: `${kind} ${String(i).padStart(2, '0')}`,
+ display_name: `${kind} ${String(i).padStart(2, '0')}`,
+ description: 'A detailed synthetic catalog entry for geometry and selection checks.',
+ scope_type: i < 12 ? 'personal' : i < 24 ? 'group' : 'public',
+ content: 'Write a short synthetic summary without variables.',
+ }));
+ H.stores.bootstrap.useBootstrapStore.setState(state => ({data: {
+ ...state.data, features: {...state.data.features, enable_chat_orchestration: true,
+ enable_web_search: true, enable_deep_source_review: true, enable_image_generation: true},
+ catalogs: {models, initial_model_selection: models[0], agents: grouped('Agent'), prompts: grouped('Prompt')},
+ orchestration: {enabled: true, capabilities: [], default_approval_mode: 'manual',
+ allow_user_approval_override: true, show_manual_controls: true, timed_approval_seconds: 8},
+ }}));
+ }""")
+
+
+def open_tools(page, width):
+ if width < 768:
+ tools = page.get_by_role("button", name="Chat tools", exact=True)
+ if tools.get_attribute("aria-expanded") == "false":
+ tools.click()
+
+
+def check_panel(page, width, height):
+ panel = page.locator(".composer-picker-panel")
+ expect(panel).to_have_count(1)
+ expect(panel).to_be_visible()
+ box = panel.bounding_box()
+ assert box and box["x"] >= 11 and box["y"] >= 11, box
+ assert box["x"] + box["width"] <= width - 11, box
+ assert box["y"] + box["height"] <= height - 11, box
+ if width < 768:
+ heights = panel.locator("button").evaluate_all("buttons => buttons.map(button => button.getBoundingClientRect().height)")
+ assert all(height >= 44 for height in heights), heights
+ overflow = page.evaluate("document.documentElement.scrollWidth > innerWidth")
+ assert not overflow
+ return panel, box
+
+
+def check_hit(option):
+ option.scroll_into_view_if_needed()
+ expect(option).to_be_visible()
+ hit = option.evaluate("""element => {
+ const r = element.getBoundingClientRect();
+ return element.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2));
+ }""")
+ assert hit, "Option is clipped or covered at its actual click point"
+
+
+def test_first_agent_option_is_hit_testable_above_tools_boundary(open_page):
+ """Also runs against the original Dropdown to reproduce the real overflow defect."""
+ page, api = open_page({"width": 360, "height": 740})
+ mount(page, api, rows=0)
+ populate(page)
+ open_tools(page, 360)
+ page.get_by_role("button", name="Orchestrate", exact=True).click()
+ page.get_by_role("button", name="Agent", exact=True).click()
+ first = page.get_by_role("listbox", name="Agent options").get_by_role("option").first
+ bounds = first.bounding_box()
+ tools = page.locator("#composer-secondary-tools").bounding_box()
+ assert bounds and bounds["y"] + bounds["height"] < tools["y"], (bounds, tools)
+ hit = first.evaluate("""element => {
+ const r = element.getBoundingClientRect();
+ return element.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2));
+ }""")
+ assert hit, f"First Agent option is clipped above the tools boundary: {bounds}, tools: {tools}"
+ first.click()
+ expect(page.get_by_role("button", name="Agent 00", exact=True)).to_be_visible()
+
+
+@pytest.mark.parametrize("width,height,theme,large", [
+ (320, 740, "light", False), (360, 740, "dark", False),
+ (393, 852, "light", True), (430, 932, "dark", False),
+ (852, 393, "dark", False), (1280, 800, "light", False),
+])
+def test_grouped_agent_and_prompt_options_escape_tools_and_remain_clickable(open_page, width, height, theme, large):
+ page, api = open_page({"width": width, "height": height})
+ mount(page, api, rows=0)
+ populate(page)
+ page.evaluate("""({theme, large}) => {
+ document.documentElement.classList.toggle('dark', theme === 'dark');
+ if (large) document.documentElement.dataset.fontSize = 'xl';
+ }""", {"theme": theme, "large": large})
+ open_tools(page, width)
+ # Enable manual selection, leaving the existing primary actions and layout unchanged.
+ page.get_by_role("button", name="Orchestrate", exact=True).click()
+ send_before = page.get_by_role("button", name="Send message", exact=True).bounding_box()
+ for kind in ("Agent", "Prompt"):
+ trigger = page.get_by_role("button", name=kind, exact=True)
+ trigger.click()
+ panel, box = check_panel(page, width, height)
+ if width < 768:
+ tools_box = page.locator("#composer-secondary-tools").bounding_box()
+ assert box["y"] < tools_box["y"], (box, tools_box)
+ assert panel.evaluate("element => !element.closest('#composer-secondary-tools')")
+ options = page.get_by_role("listbox", name=f"{kind} options").get_by_role("option")
+ expect(options).to_have_count(36)
+ check_hit(options.first)
+ if kind == "Agent":
+ ARTIFACTS.mkdir(parents=True, exist_ok=True)
+ page.screenshot(path=str(ARTIFACTS / f"agent-{width}-{theme}.png"))
+ check_hit(options.last)
+ if kind == "Prompt":
+ hint = panel.get_by_text("Tip: type / in your message to choose a prompt.", exact=True)
+ hint.scroll_into_view_if_needed()
+ hint_bounds = hint.bounding_box()
+ panel_bounds = panel.bounding_box()
+ assert hint_bounds["y"] + hint_bounds["height"] <= panel_bounds["y"] + panel_bounds["height"], hint_bounds
+ options.last.click()
+ expect(panel).to_have_count(0)
+ chosen = page.get_by_role("button", name=f"{kind} 35", exact=True)
+ chosen.click()
+ expect(page.get_by_role("option", name=f"{kind} 35", exact=False)).to_have_attribute("aria-selected", "true")
+ clear = page.get_by_role("button", name="Clear selection", exact=True)
+ check_hit(clear)
+ clear.click()
+ expect(panel).to_have_count(0)
+ expect(trigger).to_be_visible()
+ send_after = page.get_by_role("button", name="Send message", exact=True).bounding_box()
+ assert send_before == send_after
+
+
+def test_model_reasoning_keyboard_switching_and_disclosure_lifetime(open_page):
+ page, api = open_page({"width": 360, "height": 740})
+ mount(page, api, rows=0)
+ populate(page)
+ open_tools(page, 360)
+ page.get_by_role("button", name="Orchestrate", exact=True).click()
+ model = page.get_by_role("button", name="Writer", exact=True)
+ model.focus()
+ page.keyboard.press("ArrowDown")
+ check_panel(page, 360, 740)
+ expect(page.get_by_role("option", name="Writer", exact=True)).to_be_focused()
+ page.keyboard.press("End")
+ expect(page.get_by_role("option", name="Summary", exact=True)).to_be_focused()
+ page.keyboard.press("Home")
+ page.keyboard.press("Enter")
+ model.click()
+ page.keyboard.press("Escape")
+ expect(model).to_be_focused()
+ expect(page.locator(".composer-picker-panel")).to_have_count(0)
+ model.click()
+ page.get_by_role("button", name="Agent", exact=True).click()
+ expect(page.get_by_role("listbox", name="Model options")).to_have_count(0)
+ expect(page.get_by_role("listbox", name="Agent options")).to_be_visible()
+ # Upward menus can cover another row; collapse remains accessible by keyboard.
+ page.get_by_role("button", name="Chat tools", exact=True).focus()
+ page.keyboard.press("Enter")
+ expect(page.locator(".composer-picker-panel")).to_have_count(0)
+ open_tools(page, 360)
+ reasoning = page.get_by_role("button", name="Low", exact=True)
+ reasoning.click()
+ check_panel(page, 360, 740)
+ page.keyboard.press("End")
+ page.keyboard.press("Enter")
+ expect(page.get_by_role("button", name="High", exact=True)).to_be_focused()
+ page.get_by_role("button", name="High", exact=True).click()
+ page.get_by_role("textbox", name="Message", exact=True).click()
+ expect(page.locator(".composer-picker-panel")).to_have_count(0)
+ expect(page.get_by_role("textbox", name="Message", exact=True)).to_be_focused()
+ model.click()
+ page.keyboard.press("Tab")
+ expect(page.locator(".composer-picker-panel")).to_have_count(0)
+ model.click()
+ page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.setState({activeConversationId: 'conv-01'})")
+ expect(page.locator(".composer-picker-panel")).to_have_count(0)
+
+
+def test_scroll_resize_and_modal_layers(open_page):
+ page, api = open_page({"width": 320, "height": 450})
+ mount(page, api, rows=0)
+ populate(page)
+ open_tools(page, 320)
+ page.get_by_role("button", name="Orchestrate", exact=True).click()
+ page.get_by_role("button", name="Prompt", exact=True).click()
+ panel, before = check_panel(page, 320, 450)
+ scroll = page.locator("#composer-secondary-tools").evaluate("""element => {
+ const before = element.scrollTop;
+ element.scrollTop = before > 0 ? 0 : element.scrollHeight;
+ return {before, after: element.scrollTop};
+ }""")
+ assert scroll["before"] != scroll["after"], scroll
+ page.wait_for_function("""before => {
+ const panel = document.querySelector('.composer-picker-panel');
+ return !panel || Math.abs(panel.getBoundingClientRect().top - before) > 2;
+ }""", arg=before["y"])
+ if panel.count():
+ check_panel(page, 320, 450)
+ page.set_viewport_size({"width": 430, "height": 320})
+ if panel.count():
+ check_panel(page, 430, 320)
+ page.keyboard.press("Escape")
+ page.set_viewport_size({"width": 393, "height": 852})
+ page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.setState({activeConversationId: 'conv-01'})")
+ page.get_by_role("button", name="Agent", exact=True).click()
+ # Opening the real details tour dialog moves focus outside and removes the picker.
+ page.evaluate("""() => window.OrchHarness.stores.userSettings.useUserSettingsStore.setState({
+ settings: {showTutorialButtons: true},
+ })""")
+ page.get_by_role("button", name="Conversation actions", exact=True).click()
+ page.get_by_role("button", name="Take the chat tour", exact=True).click()
+ expect(page.get_by_role("dialog")).to_be_visible()
+ expect(panel).to_have_count(0)
+
+
+def test_approval_and_orchestration_model_preserve_persistence_and_manual_collapse(picker_approval_ui):
+ make_page, api = picker_approval_ui
+ configure_models(api)
+ page = make_page(393)
+ mount_approval(page, api)
+ open_tools(page, 393)
+ approval = page.get_by_title("Approval mode", exact=True)
+ approval.click()
+ check_panel(page, 393, 900)
+ page.get_by_role("option", name="After 8s", exact=False).click()
+ page.evaluate("async () => await window.OrchHarness.stores.userSettings.useUserSettingsStore.getState().flush()")
+ assert api.settings["orchestrationApprovalMode"] == "timed"
+ page.get_by_title("Manual controls", exact=True).click()
+ model = page.get_by_title("Auto chooses by task, then admin priority and favorites. A specific model stays pinned.")
+ model.click()
+ check_panel(page, 393, 900)
+ page.get_by_role("option", name="Summary", exact=True).click()
+ page.evaluate("async () => await window.OrchHarness.stores.userSettings.useUserSettingsStore.getState().flush()")
+ assert api.settings["orchestrationPreferredModelId"] == SUMMARY["selection_key"]
+ model.click()
+ page.get_by_title("Manual controls", exact=True).click()
+ expect(page.locator(".composer-picker-panel")).to_have_count(0)
+ page.get_by_title("Manual controls", exact=True).click()
+ expect(model).to_contain_text("Summary")
+ model.click()
+ page.get_by_role("option", name="Writer", exact=True).click()
+ page.evaluate("async () => await window.OrchHarness.stores.userSettings.useUserSettingsStore.getState().flush()")
+ assert api.settings["orchestrationPreferredModelId"] == WRITER["selection_key"]
+
+
+def test_empty_disabled_and_non_composer_defaults(open_page):
+ page, api = open_page({"width": 320, "height": 450})
+ mount(page, api, rows=0, experience="Dropdown",
+ props={"options": [], "placeholder": "Empty", "floating": True, "disabled": True})
+ expect(page.get_by_role("button", name="Empty", exact=True)).to_be_disabled()
+ expect(page.locator(".composer-picker-panel")).to_have_count(0)
+ mount(page, api, rows=0, experience="Dropdown", props={"options": [], "placeholder": "Empty", "floating": True})
+ page.get_by_role("button", name="Empty", exact=True).click()
+ expect(page.get_by_text("Nothing available", exact=True)).to_be_visible()
+ check_panel(page, 320, 450)
+ mount(page, api, rows=0, experience="Dropdown", props={"options": [], "placeholder": "Default"})
+ expect(page.locator(".composer-picker-panel")).to_have_count(0)
+ page.get_by_role("button", name="Default", exact=True).click()
+ expect(page.get_by_role("listbox", name="Default options")).to_be_visible()
+ assert page.get_by_role("listbox").evaluate("element => Boolean(element.closest('#mount-a'))")
+
+
+@pytest.mark.parametrize("width,height", [(320, 740), (1280, 800)])
+def test_separate_document_picker_is_not_clipped_by_tools(open_page, width, height):
+ page, api = open_page({"width": width, "height": height})
+ context_api = ContextApi()
+ page.route(re.compile(r"https://simplechat\.test/api/documents(?:/[^?]+)?(?:\?.*)?$"), context_api.handle)
+ mount(page, api, rows=0)
+ populate(page)
+ open_tools(page, width)
+ page.get_by_title("Orchestrate", exact=True).click()
+ page.get_by_title("Documents", exact=True).click()
+ panel = page.locator("[data-context-picker]")
+ expect(panel).to_be_visible()
+ document = panel.get_by_role("button", name=re.compile("^Quarterly brief"))
+ expect(document).to_be_visible()
+ check_hit(document)
+ assert panel.evaluate("element => !element.closest('#composer-secondary-tools')")
+ box = panel.bounding_box()
+ assert box and box["y"] >= 0 and box["x"] >= 0, box
+ assert box["x"] + box["width"] <= width and box["y"] + box["height"] <= height, box
+ document.click()
+ expect(document).to_have_attribute("aria-pressed", "true")
+ page.keyboard.press("Escape")
+ expect(panel).to_have_count(0)
+ assert not context_api.unexpected, context_api.unexpected
diff --git a/ui_tests/test_v2_orchestration_approval_persistence.py b/ui_tests/test_v2_orchestration_approval_persistence.py
index 86cf47fba..66bfb235d 100644
--- a/ui_tests/test_v2_orchestration_approval_persistence.py
+++ b/ui_tests/test_v2_orchestration_approval_persistence.py
@@ -1,7 +1,7 @@
# test_v2_orchestration_approval_persistence.py
"""
Browser regressions for account-level orchestration approval persistence.
-Version: 0.261.139
+Version: 0.261.327
Implemented in: 0.261.101
Drive the real Composer, router and settings store. Only HTTP is replaced: the
@@ -28,6 +28,7 @@
sys.path.insert(0, str(REPO_ROOT / "ui_tests" / "fixtures" / "orchestration"))
import harness_build as hb # noqa: E402
+from composer_helpers import open_composer_tools # noqa: E402
from playwright_connection import connect_options # noqa: E402, F401
from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
@@ -257,6 +258,7 @@ def mount(page, api):
api.bootstrap,
)
expect(message_box(page)).to_be_visible()
+ open_composer_tools(page)
def message_box(page):
diff --git a/ui_tests/test_v2_orchestration_model_picker.py b/ui_tests/test_v2_orchestration_model_picker.py
index 5ca571f1b..a59b3d80f 100644
--- a/ui_tests/test_v2_orchestration_model_picker.py
+++ b/ui_tests/test_v2_orchestration_model_picker.py
@@ -1,7 +1,7 @@
# test_v2_orchestration_model_picker.py
"""
Browser regressions for the Orchestrate model picker's placement and remembered choice.
-Version: 0.261.137
+Version: 0.261.327
Implemented in: 0.261.137
In Orchestrate the model picker used to jump out above the input, and its Auto choice lived
@@ -32,7 +32,7 @@
from test_support.versioning import assert_app_version_at_least # noqa: E402
from test_v2_orchestration_approval_persistence import ( # noqa: E402, F401
approval_assets, approval_browser, approval_ui, connect_options,
- CHAT_PATH, PLAN_PATH, mount, message_box, send_button, settings_response,
+ CHAT_PATH, PLAN_PATH, mount, message_box, send_button, settings_response, open_composer_tools,
)
pytestmark = pytest.mark.ui
@@ -77,6 +77,7 @@ def picker(page):
def open_manual(page):
+ open_composer_tools(page)
page.get_by_title("Manual controls", exact=True).click()
expect(picker(page)).to_be_visible()
diff --git a/ui_tests/test_v2_prompt_composer_experience.py b/ui_tests/test_v2_prompt_composer_experience.py
index 3c565dc6b..57bb7708b 100644
--- a/ui_tests/test_v2_prompt_composer_experience.py
+++ b/ui_tests/test_v2_prompt_composer_experience.py
@@ -1,7 +1,7 @@
# test_v2_prompt_composer_experience.py
"""
Focused browser regressions for the approved V2 prompt composer experience.
-Version: 0.261.139
+Version: 0.261.327
Implemented in: 0.261.096
Single orchestration contract updated in: 0.261.139
@@ -45,6 +45,7 @@
import harness_build as hb # noqa: E402
from functions_prompt_metadata import build_prompt_selection_metadata # noqa: E402
from test_v2_chat_context_selection import ContextApi, open_picker, pick_context # noqa: E402
+from composer_helpers import open_composer_tools # noqa: E402
from v2_admin_settings import connect_options # noqa: E402, F401
from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
@@ -460,6 +461,7 @@ def pick_prompt(page, name="Weekly brief", *, method="dropdown"):
expect(menu.get_by_role("option", name=re.compile(re.escape(name)))).to_be_visible()
draft.press("Enter")
else:
+ open_composer_tools(page)
picker = page.locator('button[aria-haspopup="listbox"]').filter(
has_text=re.compile(r"^(Prompt|Weekly brief|Alternate)$")
).last
@@ -674,7 +676,8 @@ def test_saved_prompt_editor_uses_the_same_caret_picker_without_inventing_chat_v
page.set_viewport_size({"width": width, "height": 844})
mount(page, api)
message_box(page).fill("Hello TARGET.")
- page.get_by_role("button", name="Save as prompt", exact=True).click()
+ open_composer_tools(page)
+ page.get_by_title("Save as prompt", exact=True).click()
dialog = page.get_by_role("dialog", name="New prompt", exact=True)
editor = dialog.locator("#prompt-content")
editor.evaluate("element => { element.focus(); element.setSelectionRange(6, 12); }")
@@ -1169,11 +1172,11 @@ def test_late_fill_cannot_mutate_a_newer_draft(prompt_ui, change):
}
lookup(page, api, hold=True)
expect(page.get_by_role("button", name="Cancel lookup", exact=True)).to_be_visible()
- expect(page.get_by_role("status")).to_contain_text("Searching knowledge")
+ expect(page.get_by_role("status").filter(has_text="Searching knowledge")).to_contain_text("Searching knowledge")
if change == "cancel":
page.get_by_role("button", name="Cancel lookup", exact=True).click()
- expect(page.get_by_role("status")).to_contain_text("cancelled")
+ expect(page.get_by_role("status").filter(has_text="Knowledge lookup cancelled")).to_contain_text("cancelled")
elif change == "field-edit":
variable(page, "customer").fill("Human wins")
elif change == "template-edit":
diff --git a/ui_tests/test_v2_reasoning_controls.py b/ui_tests/test_v2_reasoning_controls.py
index 703397b7f..d6c7ce291 100644
--- a/ui_tests/test_v2_reasoning_controls.py
+++ b/ui_tests/test_v2_reasoning_controls.py
@@ -1,7 +1,7 @@
# test_v2_reasoning_controls.py
"""
Real-Composer reasoning, capability selections, and saved-plan notice regressions.
-Version: 0.261.256
+Version: 0.261.327
Implemented in: 0.261.104; Image requests orchestration image proposals since 0.261.132
Reuse the local/Azure Playwright fixtures without live model, Azure or retrieval calls.
@@ -20,7 +20,7 @@
from functions_model_capabilities import resolve_model_reasoning_policy # noqa: E402
from test_v2_orchestration_approval_persistence import ( # noqa: E402, F401
approval_assets, approval_browser, approval_ui, connect_options,
- CHAT_PATH, PLAN_PATH, mount, message_box, send_button, settings_response,
+ CHAT_PATH, PLAN_PATH, mount, message_box, send_button, settings_response, open_composer_tools,
)
pytestmark = pytest.mark.ui
@@ -51,6 +51,7 @@ def configure(api, *, orchestrating=True):
def open_manual(page):
+ open_composer_tools(page)
page.get_by_title("Manual controls", exact=True).click()