From efb89d32436315dda732bca904d52266b3bea84d Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 5 Nov 2026 12:52:56 -0500 Subject: [PATCH] Fix V2 conversation menu layout and clipping Flatten reuse actions below common conversation controls, separate destructive actions, and use viewport-aware portalled menus without scrolling the sidebar. Preserve keyboard, mobile, and permission behavior with focused regression coverage and versioned documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../src/components/chat/ConversationRail.tsx | 225 +++++++++-------- .../v2_ui/src/components/ui/ActionPopover.tsx | 76 +++++- .../src/components/ui/dropdownPlacement.ts | 7 +- application/v2_ui/src/styles/theme.css | 5 + .../fixes/V2_CONVERSATION_MENU_LAYOUT_FIX.md | 127 ++++++++++ .../reference-and-send-conversations.md | 15 +- docs/reference/chat-controls.md | 12 +- .../test_v2_composer_picker_placement.py | 50 ++-- .../test_v2_sidebar_conversation_scroll.py | 32 ++- ui_tests/test_v2_conversation_handoffs.py | 6 +- ui_tests/test_v2_conversation_references.py | 13 +- .../test_v2_sidebar_conversation_scroll.py | 237 +++++++++++++++++- 13 files changed, 646 insertions(+), 161 deletions(-) create mode 100644 docs/explanation/fixes/V2_CONVERSATION_MENU_LAYOUT_FIX.md diff --git a/application/single_app/config.py b/application/single_app/config.py index 6ce8c4d2a..3774a966a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -103,7 +103,7 @@ EXECUTOR_MAX_WORKERS = 30 CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32')) SESSION_TYPE = 'filesystem' -VERSION = "0.261.405" +VERSION = "0.261.406" 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/ConversationRail.tsx b/application/v2_ui/src/components/chat/ConversationRail.tsx index 80a108248..a7b9194bd 100644 --- a/application/v2_ui/src/components/chat/ConversationRail.tsx +++ b/application/v2_ui/src/components/chat/ConversationRail.tsx @@ -16,7 +16,7 @@ // it carries the navigation above out of view, and the search (with the bulk bar under it) // is held at the top of the rail's scroll region instead of scrolling away. -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { clsx } from 'clsx'; import { Copy, @@ -61,6 +61,7 @@ import { summarizeRemoval, } from '../../lib/conversationSelection'; import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { ActionPopover } from '../ui/ActionPopover'; import { Skeleton } from '../ui/primitives'; import { ConversationExportDialog } from './ConversationExportDialog'; import { WorkflowRunningTag } from './WorkflowRunningTag'; @@ -196,6 +197,7 @@ function ConversationRow({ conversation, selected, anySelected, + scrollRootRef, onExport, onRequestDelete, onSendToConversation, @@ -203,6 +205,7 @@ function ConversationRow({ conversation: Conversation; selected: boolean; anySelected: boolean; + scrollRootRef: RefObject; onExport: (conversationId: string) => void; onRequestDelete: (conversation: Conversation) => void; onSendToConversation: (source: ConversationSource) => void; @@ -222,6 +225,12 @@ function ConversationRow({ const [menuOpen, setMenuOpen] = useState(false); const [renaming, setRenaming] = useState(false); const [draftTitle, setDraftTitle] = useState(conversation.title); + const menuTriggerRef = useRef(null); + const menuId = useId(); + const closeMenu = useCallback((returnFocus: boolean) => { + setMenuOpen(false); + if (returnFocus) menuTriggerRef.current?.focus({ preventScroll: true }); + }, []); const openPanel = useCollaborationStore((state) => state.openPanel); const currentUserId = useBootstrapStore((state) => state.data?.user?.id); const collaborationEnabled = useBootstrapStore((state) => @@ -242,6 +251,9 @@ function ConversationRow({ // legible — and it matters more here than in a table, because a plain click clears the // selection, so the user has to be able to see there is one to lose. const showCheckbox = selected || anySelected; + const menuRowClass = + 'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm font-normal disabled:cursor-not-allowed disabled:opacity-40'; + const menuItemClass = clsx(menuRowClass, 'hover:bg-surface-2'); const commitRename = () => { const trimmed = draftTitle.trim(); @@ -349,7 +361,10 @@ function ConversationRow({ {menuOpen && ( - <> - {/* Click-away layer; keeps the menu dismissible without a global listener. */} -