diff --git a/application/single_app/config.py b/application/single_app/config.py index 0cd03a65b..e86407659 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.324" +VERSION = "0.261.325" 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/ConversationBadges.tsx b/application/v2_ui/src/components/chat/ConversationBadges.tsx index 45b6d1429..79f60b2fe 100644 --- a/application/v2_ui/src/components/chat/ConversationBadges.tsx +++ b/application/v2_ui/src/components/chat/ConversationBadges.tsx @@ -31,8 +31,10 @@ const TONE_TITLE: Record = { export function ConversationBadges({ metadata, + className, }: { metadata: ConversationMetadata | null | undefined; + className?: string; }) { const classificationEnabled = useBootstrapStore((state) => Boolean(state.data?.features?.enable_document_classification), @@ -53,7 +55,7 @@ export function ConversationBadges({ } return ( -
+
{lock !== 'hidden' && ( void; + variant?: 'primary' | 'subtle' | 'ghost'; + disabled?: boolean; + loading?: boolean; + /** Tours already use an icon on desktop. */ + iconOnly?: boolean; + testId?: string; +} + +export type HeaderActionItems = readonly (HeaderAction | null | false | undefined)[]; + +export function HeaderActionControl({ action, inPopover = false, onActivate }: { + action: HeaderAction; + inPopover?: boolean; + onActivate?: () => void; +}) { + return ( + { + onActivate?.(); + action.onClick(); + }} + className={clsx('header-action shrink-0', inPopover && 'w-full justify-start', + action.iconOnly && !inPopover && 'header-action-icon-only')}> + + {action.label} + + ); +} + +export function HeaderActions({ actions }: { actions: HeaderActionItems }) { + const mobile = useMobileViewport(); + const visible = actions.filter((action): action is HeaderAction => Boolean(action)); + const overflow = mobile && visible.length > 2; + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const id = useId(); + const close = useCallback((returnFocus: boolean) => { + setOpen(false); + if (returnFocus) triggerRef.current?.focus(); + }, []); + const actionIds = visible.map((action) => action.id).join('|'); + useEffect(() => setOpen(false), [mobile, actionIds]); + + if (!visible.length) return null; + return ( +
+ {(overflow ? visible.slice(0, 1) : visible).map((action) => + )} + {overflow && ( + + )} + {overflow && open && ( + + {visible.slice(1).map((action) => + close(true)} />)} + + )} +
+ ); +} diff --git a/application/v2_ui/src/components/layout/PageHeader.tsx b/application/v2_ui/src/components/layout/PageHeader.tsx index 9c337c3dd..dbb1349c2 100644 --- a/application/v2_ui/src/components/layout/PageHeader.tsx +++ b/application/v2_ui/src/components/layout/PageHeader.tsx @@ -3,30 +3,34 @@ import type { ReactNode } from 'react'; import { MobileNavigation } from './MobileNavigation'; +import { HeaderActions, type HeaderActionItems } from './HeaderActions'; export function PageHeader({ title, description, actions, leading, + status, }: { title: string; description?: string; - actions?: ReactNode; + actions?: HeaderActionItems; + status?: ReactNode; /** Rendered before the title block, for a page identified by more than its name. */ leading?: ReactNode; }) { return ( -
+
- {leading &&
{leading}
} + {leading &&
{leading}
}

{title}

{description && (

{description}

)}
- {actions &&
{actions}
} + {status &&
{status}
} + {actions && }
); } diff --git a/application/v2_ui/src/components/tour/GuidedTour.tsx b/application/v2_ui/src/components/tour/GuidedTour.tsx index d57203eca..f344c78e7 100644 --- a/application/v2_ui/src/components/tour/GuidedTour.tsx +++ b/application/v2_ui/src/components/tour/GuidedTour.tsx @@ -100,7 +100,7 @@ export function GuidedTour({ [], ); - useEffect(() => { + useLayoutEffect(() => { const previous = document.activeElement; return () => { if (previous instanceof HTMLElement && previous.isConnected) { @@ -149,9 +149,12 @@ export function GuidedTour({ }; }, [reposition]); - useEffect(() => { - cardRef.current?.focus(); - }, [index]); + const cardVisible = !step || cardPosition !== null; + useLayoutEffect(() => { + if (cardVisible) { + cardRef.current?.focus(); + } + }, [index, cardVisible]); const last = index >= steps.length - 1; const next = useCallback(() => { diff --git a/application/v2_ui/src/components/tour/TourLauncher.tsx b/application/v2_ui/src/components/tour/TourLauncher.tsx index 5fc5112ef..d313e250f 100644 --- a/application/v2_ui/src/components/tour/TourLauncher.tsx +++ b/application/v2_ui/src/components/tour/TourLauncher.tsx @@ -13,8 +13,9 @@ import { CircleHelp } from 'lucide-react'; import { useUserSettingsStore } from '../../stores/userSettingsStore'; import { consumeTourRequest, findTour, isTourEnabled } from '../../lib/tours'; import { GuidedTour } from './GuidedTour'; +import type { HeaderAction } from '../layout/HeaderActions'; -export function TourLauncher({ tourId, className }: { tourId: string; className?: string }) { +export function useTourLauncher(tourId: string, available = true) { const tour = findTour(tourId); const [open, setOpen] = useState(false); const settingsLoading = useUserSettingsStore((state) => state.loading); @@ -23,6 +24,7 @@ export function TourLauncher({ tourId, className }: { tourId: string; className? ); useEffect(() => { + if (!available) return; // Gives the page a moment to draw the controls the tour points at. The request is // consumed inside the timer so a mount that is torn down early leaves it in place. const timer = window.setTimeout(() => { @@ -31,29 +33,39 @@ export function TourLauncher({ tourId, className }: { tourId: string; className? } }, 400); return () => window.clearTimeout(timer); - }, [tourId]); + }, [tourId, available]); + + const label = tour ? `Take the ${tour.title.toLowerCase()}` : ''; + const action: HeaderAction | null = tour && available && enabled && !settingsLoading ? { + id: `tour-${tourId}`, label, icon: , + onClick: () => setOpen(true), iconOnly: true, + } : null; + return { + action, + dialog: tour && open ? setOpen(false)} /> : null, + }; +} - if (!tour) { - return null; - } +export function TourLauncher({ tourId, className }: { tourId: string; className?: string }) { + const { action, dialog } = useTourLauncher(tourId); return ( <> - {enabled && !settingsLoading && ( + {action && ( )} - {open && setOpen(false)} />} + {dialog} ); } diff --git a/application/v2_ui/src/components/ui/ActionPopover.tsx b/application/v2_ui/src/components/ui/ActionPopover.tsx new file mode 100644 index 000000000..eb212bd6b --- /dev/null +++ b/application/v2_ui/src/components/ui/ActionPopover.tsx @@ -0,0 +1,96 @@ +// ActionPopover.tsx +// A bounded overlay for header actions; its host stays outside clipped page panes. + +import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react'; +import { createPortal } from 'react-dom'; + +export function ActionPopover({ + id, label, anchorRef, onClose, children, inline = false, +}: { + id: string; + label: string; + anchorRef: RefObject; + onClose: (returnFocus: boolean) => void; + children: ReactNode; + inline?: boolean; +}) { + const panelRef = useRef(null); + const [position, setPosition] = useState({ top: 0, left: 8, maxHeight: 320, width: 272 }); + + useLayoutEffect(() => { + if (inline) return; + const measure = () => { + const anchor = anchorRef.current; + if (!anchor) return; + const rect = anchor.getBoundingClientRect(); + const width = Math.min(272, window.innerWidth - 16); + const top = Math.min(rect.bottom + 8, window.innerHeight - 52); + setPosition({ + top, width, + left: Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8)), + maxHeight: Math.max(44, window.innerHeight - top - 8), + }); + }; + measure(); + const first = Array.from(panelRef.current?.querySelectorAll( + 'button:not(:disabled), a[href]', + ) ?? []).find((control) => control.getClientRects().length > 0); + (first ?? panelRef.current)?.focus(); + const outside = (event: PointerEvent) => { + if (event.target instanceof Node && + !panelRef.current?.contains(event.target) && !anchorRef.current?.contains(event.target)) { + onClose(false); + } + }; + const focusOutside = (event: FocusEvent) => { + if (event.target instanceof Node && + !panelRef.current?.contains(event.target) && !anchorRef.current?.contains(event.target)) { + onClose(false); + } + }; + document.addEventListener('pointerdown', outside); + document.addEventListener('focusin', focusOutside); + window.addEventListener('resize', measure); + window.addEventListener('scroll', measure, true); + return () => { + document.removeEventListener('pointerdown', outside); + document.removeEventListener('focusin', focusOutside); + window.removeEventListener('resize', measure); + window.removeEventListener('scroll', measure, true); + }; + }, [anchorRef, onClose, inline]); + + if (inline) return <>{children}; + return createPortal( +
{ + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onClose(true); + } + if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'Tab'].includes(event.key)) { + const controls = Array.from(event.currentTarget.querySelectorAll( + 'button:not(:disabled), a[href]', + )).filter((control) => control.getClientRects().length > 0); + const current = controls.indexOf(document.activeElement as HTMLElement); + if (event.key === 'Tab') { + if ((event.shiftKey && current === 0) || (!event.shiftKey && current === controls.length - 1)) { + event.preventDefault(); + onClose(true); + } + return; + } + const next = event.key === 'Home' ? 0 : event.key === 'End' ? controls.length - 1 + : (current + (event.key === 'ArrowDown' ? 1 : -1) + controls.length) % controls.length; + event.preventDefault(); + controls[next]?.focus(); + } + }}> + {children} +
, + document.body, + ); +} diff --git a/application/v2_ui/src/dev/AlertLabPage.tsx b/application/v2_ui/src/dev/AlertLabPage.tsx index 23f74264e..8957b30ef 100644 --- a/application/v2_ui/src/dev/AlertLabPage.tsx +++ b/application/v2_ui/src/dev/AlertLabPage.tsx @@ -244,18 +244,11 @@ export function AlertLabPage() { title="Alert lab" description="Dev only. Sample workflow alerts in the real shell; nothing reaches the server." leading={