From f35e273977c787c569f30f8182e7b9b938dc5546 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 5 Oct 2026 15:53:33 -0400 Subject: [PATCH 1/2] V2: scroll the chat rail as one panel and pop workflow alerts from the bell On the chat page the rail kept its full height for the navigation and the link groups, and the conversation list scrolled in what was left: two rows in a 720px window with two external links. Everything between New chat and the footer now scrolls as one panel, as the classic sidebar does. Reading down the list carries the navigation out of view and holds Search chats (and the bulk bar) right under New chat. The collapsed strip and other pages are unchanged. - ConversationRail has no scroller of its own; its header is sticky and marked data-stuck while held (solid backing and edge only then). - Paging and the stuck marker observe the rail's region, so the 120px look-ahead now applies. - The section keeps the region's height while searching, so narrowing results cannot drag the search box out from under the cursor. - Keyboard focus on a row under the held search is moved clear of it (WCAG 2.4.11); mouse clicks are left alone. - The workflow alert notice now hangs from the bell instead of My Workspace, which the rail can scroll away: below the bell like its panel when expanded, beside it when collapsed or on a phone. Version 0.261.235. Fixes #1642. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../src/components/chat/ConversationRail.tsx | 125 +++- .../components/layout/NotificationBell.tsx | 16 +- .../v2_ui/src/components/layout/Sidebar.tsx | 100 ++-- .../notifications/WorkflowAlertNotice.css | 4 +- .../notifications/WorkflowAlertNotice.tsx | 35 +- docs/explanation/features/REACT_V2_UI.md | 16 + .../features/V2_WORKFLOW_ALERT_NOTICES.md | 34 +- .../V2_SIDEBAR_CONVERSATION_SCROLL_FIX.md | 176 ++++++ docs/guides/manage-notifications.md | 17 +- docs/reference/chat-controls.md | 18 +- .../test_v2_sidebar_conversation_scroll.py | 298 ++++++++++ .../test_v2_sidebar_conversation_scroll.py | 547 ++++++++++++++++++ ui_tests/test_v2_workflow_alert_notices.py | 114 +++- 14 files changed, 1392 insertions(+), 110 deletions(-) create mode 100644 docs/explanation/fixes/V2_SIDEBAR_CONVERSATION_SCROLL_FIX.md create mode 100644 functional_tests/test_v2_sidebar_conversation_scroll.py create mode 100644 ui_tests/test_v2_sidebar_conversation_scroll.py diff --git a/application/single_app/config.py b/application/single_app/config.py index cbd13de68..822816685 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.234" +VERSION = "0.261.235" 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 e243062a3..81bcd19d0 100644 --- a/application/v2_ui/src/components/chat/ConversationRail.tsx +++ b/application/v2_ui/src/components/chat/ConversationRail.tsx @@ -11,6 +11,10 @@ // // The bulk bar appears only while something is selected, in the slot the old "Select" button // used to occupy permanently. +// +// The list has no scrollbar of its own. It scrolls with the rail around it, so reading down +// 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 { clsx } from 'clsx'; @@ -49,9 +53,12 @@ import { import { ConfirmDialog } from '../ui/ConfirmDialog'; import { Skeleton } from '../ui/primitives'; import { ConversationExportDialog } from './ConversationExportDialog'; -import type { ReactNode } from 'react'; +import type { FocusEvent, ReactNode, RefObject } from 'react'; import type { Conversation } from '../../lib/types'; +/** Space left between the held search and a row control focus has been moved out from under it. */ +const FOCUS_CLEARANCE_PX = 8; + /** * Tag colours, matching the badges shown beside an open conversation's title so the list * and the header describe a conversation the same way. @@ -451,7 +458,12 @@ function BulkAction({ ); } -export function ConversationRail() { +export function ConversationRail({ + scrollRootRef, +}: { + /** The rail's scroll region, which this list scrolls with rather than on its own. */ + scrollRootRef: RefObject; +}) { const { conversations, conversationsLoading, @@ -472,6 +484,8 @@ export function ConversationRail() { const sentinelRef = useRef(null); const selectAllRef = useRef(null); + const headerRef = useRef(null); + const stuckMarkerRef = useRef(null); /** * The export in flight, or null. @@ -534,7 +548,10 @@ export function ConversationRail() { }, []); // Paging is driven by an IntersectionObserver rather than a scroll handler so it does - // not fire on every scroll frame. + // not fire on every scroll frame. It watches the rail's scroll region rather than the + // viewport: measured against the viewport, the region's edge clipped the end of the list + // before the look-ahead margin could reach it, so the next page waited until the last + // row was already on screen. useEffect(() => { const sentinel = sentinelRef.current; if (!sentinel || !hasMore) { @@ -547,12 +564,74 @@ export function ConversationRail() { void loadMore(); } }, - { rootMargin: '120px' }, + { root: scrollRootRef.current, rootMargin: '120px' }, ); observer.observe(sentinel); return () => observer.disconnect(); - }, [hasMore, loadMore]); + }, [hasMore, loadMore, scrollRootRef]); + + /** + * Mark the search header while it is stuck to the top of the rail. + * + * Stuck, the header needs a solid backing so rows passing beneath it do not show through + * the glass, and an edge where the list starts. At rest it stays transparent, so the rail + * looks as it always has. The marker is written to the node rather than held in state: + * state would re-render every row each time the header crossed the threshold. + * + * "Stuck" rather than "pinned", which in this list means a pinned conversation. + */ + useEffect(() => { + const marker = stuckMarkerRef.current; + const header = headerRef.current; + const root = scrollRootRef.current; + if (!marker || !header || !root) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + const rootTop = entry.rootBounds?.top ?? root.getBoundingClientRect().top; + // Out of view because it scrolled up past the region's top, not because the + // whole list sits below a short rail. + const stuck = !entry.isIntersecting && entry.boundingClientRect.top < rootTop; + header.toggleAttribute('data-stuck', stuck); + }, + { root }, + ); + + observer.observe(marker); + return () => observer.disconnect(); + }, [scrollRootRef]); + + /** + * Keep keyboard focus clear of the held header (WCAG 2.4.11). + * + * A row control reached with Tab or Shift+Tab that sits under the held search is moved + * just below it. CSS cannot do this here: Chromium ignores a control's scroll-margin when + * the control is already inside the scroll region, and scroll-padding on the region also + * applies to the held search box, so focusing the search threw the list hundreds of pixels. + * + * Only keyboard focus is moved. A click lands on the part of the row the reader can see, + * and scrolling the row out from under the pointer between press and release would leave + * the release on another row and swallow the click. + */ + const keepFocusClearOfHeader = (event: FocusEvent) => { + const header = headerRef.current; + const root = scrollRootRef.current; + const target = event.target; + if (!header || !root || !(target instanceof HTMLElement) || !target.matches(':focus-visible')) { + return; + } + const covered = header.getBoundingClientRect().bottom - target.getBoundingClientRect().top; + if (covered > 0) { + root.scrollTop -= covered + FOCUS_CLEARANCE_PX; + } + }; const confirmRemoval = () => { if (!pendingRemoval) { @@ -578,7 +657,14 @@ export function ConversationRail() { return (
{ // A dialog opened from the rail portals out of it, but focus stays on the // control that opened it — so without this guard, Escape would dismiss the @@ -603,7 +689,24 @@ export function ConversationRail() { } }} > -
+ {/* Where the header sits at rest. Once this has scrolled up past the top of the + rail, the header is being held there. */} +