diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 17e8f3b6e8..c43bd7f6a3 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -272,19 +272,38 @@ export default function BadgeRowProvider({ const mcpServerManager = useMCPServerManager({ conversationId, storageContextKey }); - const value: BadgeRowContextType = { - skills, - memory, - webSearch, - artifacts, - fileSearch, - agentsConfig, - conversationId, - storageContextKey, - codeInterpreter, - searchApiKeyForm, - mcpServerManager, - }; + /* Memoized because this is an inline child of `ChatForm`, which re-renders on + every keystroke: a fresh value here invalidated every consumer's memo, and + the palette rebuilt its whole tool, skill and server catalog per character + typed. */ + const value = useMemo( + () => ({ + skills, + memory, + webSearch, + artifacts, + fileSearch, + agentsConfig, + conversationId, + storageContextKey, + codeInterpreter, + searchApiKeyForm, + mcpServerManager, + }), + [ + skills, + memory, + webSearch, + artifacts, + fileSearch, + agentsConfig, + conversationId, + storageContextKey, + codeInterpreter, + searchApiKeyForm, + mcpServerManager, + ], + ); return {children}; } diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 9adee8be5d..b9a17191e0 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -135,7 +135,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje className={cn( 'flex flex-col', isLandingPage - ? 'flex-1 items-center justify-end transition-transform duration-200 ease-out sm:justify-center' + ? 'flex-1 items-center justify-end transition-transform duration-200 ease-out motion-reduce:transition-none sm:justify-center' : 'h-full overflow-y-auto', )} > diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index a886ab2d81..2c67d51fb4 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -618,7 +618,7 @@ const ChatForm = memo(function ChatForm({ it reads as the input listening rather than as a widget bolted on. Once words arrive the transcript takes over. */ 0 && !dictating ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', @@ -342,9 +355,13 @@ function Bar({ role="list" aria-label={localize('com_ui_composer_tools')} aria-hidden={dictating} + /* Hidden from a reader and unreachable by a pointer, but every chip + still holds a remove button that Tab would land on. `inert` is what + takes those out of the tab order along with everything else. */ + {...(dictating ? { inert: '' } : {})} className={cn( 'flex min-w-0 flex-wrap items-center gap-1.5', - 'transition-[transform,opacity] duration-200 ease-out', + 'transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none', dictating ? 'pointer-events-none translate-y-3 opacity-0' : 'translate-y-0 opacity-100', )} > @@ -367,9 +384,10 @@ function Bar({
- +
+ + {dictationStatus} + {showSpeech && ( = { + ArrowLeft: -1, + ArrowUp: -1, + ArrowRight: 1, + ArrowDown: 1, +}; + const THUMB_FADE_MS = 140; type Localize = (key: TranslationKeys) => string; @@ -70,6 +79,9 @@ function Effort({ setting, conversation, setOption }: EffortProps) { /** Ref drives the gesture (pointermove fires before a state flush would land); * the state only feeds styling. */ const draggingRef = useRef(false); + const reducedMotion = useReducedMotion(); + /** One per stop, so an arrow key can move focus along with the selection. */ + const stopRefs = useRef<(HTMLButtonElement | null)[]>([]); const [dragging, setDragging] = useState(false); const { levels, autoValue } = useMemo(() => { @@ -196,11 +208,28 @@ function Effort({ setting, conversation, setOption }: EffortProps) { const fillWidth = (index: number) => `calc(${THUMB / 2 + TRACK_H / 2}px + ${ratioOf(index)} * (100% - ${THUMB}px))`; - const description = setting.description != null ? String(setting.description) : undefined; - const descriptionText = - description != null ? localize(description as TranslationKeys) : undefined; + /* `descriptionCode` is what says whether this is a translation key or the + literal text an admin wrote. Translating it either way sent literal text + through i18next, which reads anything before a colon as a namespace and + silently drops it. */ + let descriptionText: string | undefined; + if (setting.description != null && setting.description !== '') { + descriptionText = + setting.descriptionCode === true + ? localize(setting.description as TranslationKeys) + : setting.description; + } const thumbSize = dragging ? THUMB_ACTIVE : THUMB; - const moveMs = dragging ? 75 : 150; + /* The track's motion is written inline, where a stylesheet's reduced-motion + rule cannot reach it, so the durations collapse here instead. */ + let moveMs = dragging ? 75 : 150; + let fadeMs = FILL_FADE_MS; + let thumbFadeMs = THUMB_FADE_MS; + if (reducedMotion) { + moveMs = 0; + fadeMs = 0; + thumbFadeMs = 0; + } return (
@@ -275,7 +304,7 @@ function Effort({ setting, conversation, setOption }: EffortProps) { opacity: isAuto ? 0 : 1, /* Fades out faster than the thumb above it, so it is already gone before the thumb starts uncovering the rail. */ - transition: `width ${moveMs}ms ease-out, opacity ${FILL_FADE_MS}ms ease-out`, + transition: `width ${moveMs}ms ease-out, opacity ${fadeMs}ms ease-out`, }} className="absolute left-0 top-1/2 -translate-y-1/2 rounded-full bg-green-500" /> @@ -318,7 +347,7 @@ function Effort({ setting, conversation, setOption }: EffortProps) { `left ${moveMs}ms ease-out`, `height ${moveMs}ms ease-out`, `width ${moveMs}ms ease-out`, - `opacity ${THUMB_FADE_MS}ms ease-out ${isAuto ? `${FILL_FADE_MS}ms` : '0ms'}`, + `opacity ${thumbFadeMs}ms ease-out ${isAuto ? `${fadeMs}ms` : '0ms'}`, ].join(', '), }} className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-md" @@ -331,6 +360,24 @@ function Effort({ setting, conversation, setOption }: EffortProps) { role="radio" aria-checked={!isAuto && index === activeIndex} aria-label={label(value)} + /* One stop for the whole group, as a radiogroup is meant to have: + Tab reaches the current level and leaves, and the arrow keys move + between them. Under Auto no level is checked, so the one that + would be restored takes the tab stop. */ + tabIndex={index === (isAuto ? restoreIndex : activeIndex) ? 0 : -1} + onKeyDown={(event) => { + const step = ARROW_STEP[event.key]; + if (step === undefined) { + return; + } + event.preventDefault(); + const next = Math.min(Math.max(index + step, 0), levels.length - 1); + select(levels[next]); + stopRefs.current[next]?.focus(); + }} + ref={(node) => { + stopRefs.current[index] = node; + }} onClick={() => select(value)} style={{ left: `${(index / levels.length) * 100}%`, width: `${100 / levels.length}%` }} className="absolute inset-y-0 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" @@ -385,9 +432,16 @@ function Effort({ setting, conversation, setOption }: EffortProps) { {descriptionText != null && descriptionText !== '' && ( - + {/* A button rather than a bare span: this is the only place + the provider's own explanation of the parameter appears, + and hovering was the only way to reach it. */} + diff --git a/client/src/components/Chat/Input/Composer/Palette.tsx b/client/src/components/Chat/Input/Composer/Palette.tsx index e7bb175fdf..938c552ce9 100644 --- a/client/src/components/Chat/Input/Composer/Palette.tsx +++ b/client/src/components/Chat/Input/Composer/Palette.tsx @@ -24,6 +24,7 @@ import type { ExtendedFile, FileSetter } from '~/common'; import type { TranslationKeys } from '~/hooks'; import FilePreview from '~/components/Chat/Input/Files/FilePreview'; import { SharePointPickerDialog } from '~/components/SharePoint'; +import useReducedMotion from '~/hooks/Generic/useReducedMotion'; import useToolFavorites from '~/hooks/Input/useToolFavorites'; import useElementSize from '~/hooks/Generic/useElementSize'; import useRecentFiles from '~/hooks/Input/useRecentFiles'; @@ -60,6 +61,11 @@ const ROW_SHIFT_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)'; /** Separator for the row-set signature; cannot occur in a row key. */ const KEY_SEP = '\u0000'; const NO_ENTERING: ReadonlySet = new Set(); +const NO_ROWS: PaletteRow[] = []; + +/** A row's element id, derived from its identity so the combobox keeps naming + * the same row as the list rearranges under it. */ +const rowElementId = (key: string) => `palette-row-${key.replace(/[^\w:-]/g, '_')}`; const SECTION_LABEL: Record = { tool: 'com_ui_composer_tools', @@ -198,13 +204,21 @@ function Palette({ /* What the disclosure says it is: the moment it is clicked shut it reads as closed, while the rows it is closing over are still fading out. */ const expanded = showAllAttach && !collapsing; - const [activeIndex, setActiveIndex] = useState(0); + /* The row itself, not where it currently sits. A row that moves — starred + into favourites, pushed down by a disclosure — keeps the highlight, and the + id the combobox points at keeps naming something that exists. */ + const [activeKey, setActiveKey] = useState(''); + /* Only a keyboard move scrolls the list. Driving this from the highlight + alone let hovering a half-visible row scroll it into view, which moved the + rows under a resting pointer. */ + const [scrollToActive, setScrollToActive] = useState(false); const inputRef = useRef(null); const listRef = useRef(null); const listBodyRef = useRef(null); const disclosureRef = useRef(null); const setLift = useSetRecoilState(store.composerLiftFamily(index)); const { ref: popoverRef, height: popupHeight } = useElementSize(); + const reducedMotion = useReducedMotion(); /* Rather than let the popup push the document taller — which puts a scrollbar on a page that had none — the landing screen raises itself by whatever the @@ -281,6 +295,13 @@ function Palette({ /** One pass over each source: filter by query, split favourites out, then * flatten to the row model the virtualized list renders from. */ const rows = useMemo(() => { + /* The disclosure button lives here too, so this component stays mounted for + the whole conversation. Deriving the list while the popup is down meant + walking the entire catalog on every keystroke in the message box, for + rows nobody was looking at. */ + if (!mounted) { + return NO_ROWS; + } const favoriteMatches: PaletteEntry[] = []; const buckets: Record = { tool: [], skill: [], mcp: [] }; @@ -380,6 +401,7 @@ function Palette({ return next; }, [ + mounted, entries, favorites.keys, query, @@ -463,19 +485,20 @@ function Palette({ the same entry animated. Playing the move after the re-insertion, rather than asking the browser to notice it, is what makes the two symmetric. */ const previousTops = useRef(layout.tops); + const measuredSignature = useRef(signature); useLayoutEffect(() => { - listRef.current?.recomputeRowHeights(0); + /* Dropping the cache re-walks every offset from the top of the list, so it + is worth doing only when the rows themselves changed. */ + if (measuredSignature.current !== signature) { + measuredSignature.current = signature; + listRef.current?.recomputeRowHeights(0); + } const before = previousTops.current; previousTops.current = layout.tops; const body = listBodyRef.current; /* Where there is no Web Animations API to play the move with, the rows just arrive where they belong — the same as asking for no motion. */ - if ( - instant || - body == null || - typeof body.animate !== 'function' || - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ) { + if (instant || body == null || typeof body.animate !== 'function' || reducedMotion) { return; } for (const element of body.querySelectorAll('[data-row-key]')) { @@ -490,7 +513,7 @@ function Palette({ easing: ROW_SHIFT_EASING, }); } - }, [layout, instant]); + }, [layout, signature, instant, reducedMotion]); /** Second beat of the close: the faded rows give up their space. */ useEffect(() => { @@ -500,26 +523,30 @@ function Palette({ const timer = window.setTimeout(() => { setCollapsing(false); setShowAllAttach(false); - setKeepKey(MORE_ROW_KEY); }, ROW_FADE_MS); return () => window.clearTimeout(timer); }, [collapsing]); const firstSelectable = useMemo(() => rows.findIndex(isSelectable), [rows]); - /** Keep the highlight on a real row as the query narrows the list. */ - const [lastRowsKey, setLastRowsKey] = useState(''); - const [keepKey, setKeepKey] = useState(''); - const rowsKey = `${rows.length}:${rows[0]?.key ?? ''}`; - if (rowsKey !== lastRowsKey) { - setLastRowsKey(rowsKey); - const kept = keepKey === '' ? -1 : rows.findIndex((row) => row.key === keepKey); - const fallback = firstSelectable === -1 ? 0 : firstSelectable; - setActiveIndex(kept === -1 ? fallback : kept); - if (keepKey !== '') { - setKeepKey(''); + /** Where the active row sits now, falling back to the first row that can be + * chosen once the query has filtered the old one away. */ + const activeIndex = useMemo(() => { + const found = activeKey === '' ? -1 : rows.findIndex((row) => row.key === activeKey); + if (found !== -1) { + return found; } - } + return firstSelectable === -1 ? 0 : firstSelectable; + }, [activeKey, rows, firstSelectable]); + + const activeRow = rows[activeIndex]; + + /* Stable across renders: the list compares this by identity and throws away + its whole style cache whenever it changes. */ + const measureRow = useCallback( + ({ index: row }: { index: number }) => rowHeight(rows[row]), + [rows], + ); /* Cleared on unmount rather than on close: the popup stays up through its leave animation, so clearing on close emptied the field and repopulated the @@ -537,18 +564,17 @@ function Palette({ if (rows.length === 0) { return; } - setActiveIndex((prev) => { - let next = prev; - for (let i = 0; i < rows.length; i++) { - next = (next + direction + rows.length) % rows.length; - if (isSelectable(rows[next])) { - return next; - } + let next = activeIndex; + for (let i = 0; i < rows.length; i++) { + next = (next + direction + rows.length) % rows.length; + if (isSelectable(rows[next])) { + setActiveKey(rows[next].key); + setScrollToActive(true); + return; } - return prev; - }); + } }, - [rows], + [rows, activeIndex], ); const activate = useCallback( @@ -566,7 +592,6 @@ function Palette({ The disclosure slides down past the rows it just revealed, so the highlight is asked to follow it rather than snap back to the top. */ if (row.type === 'more') { - setKeepKey(row.key); if (showAllAttach) { setCollapsing(true); return; @@ -669,11 +694,14 @@ function Palette({ key={row.key} data-row-key={row.key} style={style} - id={`palette-row-${index}`} + id={rowElementId(row.key)} role="option" aria-selected={isActive} onClick={() => activate(index)} - onMouseEnter={() => setActiveIndex(index)} + onMouseEnter={() => { + setActiveKey(row.key); + setScrollToActive(false); + }} className={cn( 'flex cursor-pointer items-center gap-2.5 rounded-lg px-2 text-sm text-text-secondary', isActive && 'bg-surface-hover', @@ -696,11 +724,14 @@ function Palette({ key={row.key} data-row-key={row.key} style={style} - id={`palette-row-${index}`} + id={rowElementId(row.key)} role="option" aria-selected={isActive} onClick={() => activate(index)} - onMouseEnter={() => setActiveIndex(index)} + onMouseEnter={() => { + setActiveKey(row.key); + setScrollToActive(false); + }} className={cn( 'flex cursor-pointer items-center gap-2.5 rounded-lg px-2 text-sm text-text-secondary', isActive && 'bg-surface-hover', @@ -750,7 +781,7 @@ function Palette({ key={row.key} data-row-key={row.key} style={style} - id={`palette-row-${index}`} + id={rowElementId(row.key)} role="option" aria-selected={isActive} aria-checked={isEntry ? checked : undefined} @@ -758,7 +789,10 @@ function Palette({ favorited ? `${label}, ${localize('com_ui_tools_view_favorites')}` : undefined } onClick={() => activate(index)} - onMouseEnter={() => setActiveIndex(index)} + onMouseEnter={() => { + setActiveKey(row.key); + setScrollToActive(false); + }} className={cn( 'group/row relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2 text-sm', /* On-state reads as a left accent plus full-strength text, rather @@ -936,10 +970,14 @@ function Palette({ 0} autoComplete="off" aria-controls="composer-palette-list" - aria-activedescendant={rows.length > 0 ? `palette-row-${activeIndex}` : undefined} + aria-activedescendant={ + activeRow != null && isSelectable(activeRow) + ? rowElementId(activeRow.key) + : undefined + } aria-describedby="composer-palette-help" placeholder={localize('com_ui_composer_palette_search')} value={search} @@ -971,9 +1009,18 @@ function Palette({ width={width} overscanRowCount={8} rowCount={rows.length} - scrollToIndex={activeIndex} + /* The list defaults to a `grid` of `row`s, labelled + "grid" in English and holding its own tab stop. Left + alone it sits between this listbox and its options, so + none of them are owned by it, and it announces a + second, empty widget where the rows should be. */ + role="presentation" + containerRole="presentation" + aria-label="" + tabIndex={-1} + scrollToIndex={scrollToActive ? activeIndex : undefined} rowRenderer={rowRenderer} - rowHeight={({ index }) => rowHeight(rows[index])} + rowHeight={measureRow} height={Math.min(layout.height, LIST_MAX_HEIGHT)} className={cn( 'focus:outline-none', diff --git a/client/src/components/Chat/Input/Composer/Queue.tsx b/client/src/components/Chat/Input/Composer/Queue.tsx index 919b7d5b35..7879882d06 100644 --- a/client/src/components/Chat/Input/Composer/Queue.tsx +++ b/client/src/components/Chat/Input/Composer/Queue.tsx @@ -2,7 +2,7 @@ import { memo, useRef, useState, useCallback } from 'react'; import { useRecoilValue } from 'recoil'; import { useDrag, useDrop } from 'react-dnd'; import { useMediaQuery } from '@librechat/client'; -import { X, Clock, Pencil, GripVertical } from 'lucide-react'; +import { X, Pencil, GripVertical } from 'lucide-react'; import type { TMessage } from 'librechat-data-provider'; import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering'; import type { QueuedMessage } from '~/store/families'; @@ -105,7 +105,7 @@ function QueueRow({ const move = useCallback( (offset: number) => { const target = index + offset; - if (target < 0 || target >= total) { + if (!reorderable || target < 0 || target >= total) { return; } reorderQueued(message.id, target); @@ -114,7 +114,7 @@ function QueueRow({ had; the position it reports is what changed. */ gripRef.current?.focus(); }, - [index, total, reorderQueued, message.id, onAnnounce, localize], + [index, total, reorderable, reorderQueued, message.id, onAnnounce, localize], ); drop(rowRef); @@ -136,38 +136,40 @@ function QueueRow({ isDragging && 'opacity-40', )} > - {reorderable ? ( - - ) : ( -