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()