diff --git a/application/single_app/config.py b/application/single_app/config.py index 822816685..fb120a060 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.235" +VERSION = "0.261.236" 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. */} +