fix: composer accessibility and palette render cost

The virtualized list added its own grid role and tab stop between the
listbox and its options, and the highlight followed a position rather than
a row, so starring one left aria-activedescendant naming a header. Hidden
chips stayed tabbable during a recording, the effort track answered no
arrow keys, and dictation was never announced.

The tool catalog was also rebuilt on every keystroke, from unstable
capabilities, an unmemoized context value and a row model derived while
the palette was closed. Every dictation take leaked an audio context.
This commit is contained in:
Marco Beretta 2026-07-27 19:12:53 +02:00
parent 608d6c06cd
commit 406dede084
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
17 changed files with 492 additions and 162 deletions

View file

@ -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<BadgeRowContextType>(
() => ({
skills,
memory,
webSearch,
artifacts,
fileSearch,
agentsConfig,
conversationId,
storageContextKey,
codeInterpreter,
searchApiKeyForm,
mcpServerManager,
}),
[
skills,
memory,
webSearch,
artifacts,
fileSearch,
agentsConfig,
conversationId,
storageContextKey,
codeInterpreter,
searchApiKeyForm,
mcpServerManager,
],
);
return <BadgeRowContext.Provider value={value}>{children}</BadgeRowContext.Provider>;
}

View file

@ -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',
)}
>

View file

@ -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. */
<Waveform
levels={dictation.levels}
active={dictation.active}
className={cn(
'pointer-events-none absolute inset-y-0 h-full',
isMoreThanThreeRows ? 'left-5 right-2' : 'inset-x-5',

View file

@ -212,7 +212,7 @@ function Bar({
}: BarProps) {
const localize = useLocalize();
const context = useBadgeRowContext();
const allEntries = usePaletteEntries({ conversationId, agentId });
const allEntries = usePaletteEntries({ conversationId, agentId, enabled: showTools });
/* Servers with required variables open this before they can be selected; it
lives here rather than in the palette so dismissing the popover mid-config
@ -245,6 +245,17 @@ function Bar({
);
const dictating = dictation.active || dictation.transcribing;
/* Spoken rather than shown: the recording state is otherwise carried only by
two buttons quietly changing their names, which a reader will not re-read
for a control that already has focus. The elapsed seconds stay out of it,
since a region that changes every second is read every second. */
let dictationStatus = '';
if (dictation.transcribing) {
dictationStatus = localize('com_ui_transcribing');
} else if (dictating) {
dictationStatus = localize('com_ui_listening');
}
/* The arrangement is frozen for the length of a recording. The controls
shrink to just the elapsed time while one runs, which would otherwise let
chips qualify for the bottom row and re-mount there mid-animation: a chip
@ -288,8 +299,10 @@ function Bar({
dictating: height has nothing to animate between unless something
supplies the two ends, and this supplies them without measuring. */}
<div
aria-hidden={above.length === 0 || dictating}
{...(above.length === 0 || dictating ? { inert: '' } : {})}
className={cn(
'grid transition-[grid-template-rows,opacity] duration-200 ease-out',
'grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none',
above.length > 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({
<div className="grid">
<div
aria-hidden={dictating}
{...(dictating ? { inert: '' } : {})}
className={cn(
'col-start-1 row-start-1 flex items-center justify-end 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',
@ -383,19 +401,23 @@ function Bar({
it comes forward into the row and recedes back out of it. */}
<div
aria-hidden={!dictating}
{...(dictating ? {} : { inert: '' })}
className={cn(
'col-start-1 row-start-1 flex origin-center items-center justify-end px-1',
'transition-[opacity,transform] duration-200 ease-out',
'transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none',
dictating ? 'scale-100 opacity-100' : 'pointer-events-none scale-90 opacity-0',
)}
>
<span className="text-xs tabular-nums text-text-secondary">
<span className="text-xs tabular-nums text-text-secondary" aria-hidden="true">
{dictation.transcribing
? localize('com_ui_transcribing')
: formatElapsed(dictation.elapsed)}
</span>
</div>
</div>
<span role="status" aria-live="polite" className="sr-only">
{dictationStatus}
</span>
{showSpeech && (
<RoundButton
label={dictating ? localize('com_ui_stop') : localize('com_ui_use_micrphone')}

View file

@ -5,6 +5,7 @@ import { HoverCard, HoverCardTrigger, HoverCardContent, HoverCardPortal } from '
import type { SettingDefinition, TConversation } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import type { TSetOption } from '~/common';
import useReducedMotion from '~/hooks/Generic/useReducedMotion';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -23,6 +24,14 @@ const THUMB = 28;
const THUMB_ACTIVE = 32;
/** The fill clears before the thumb that covers it starts to fade. */
const FILL_FADE_MS = 90;
/** Which way each arrow key moves along the track. */
const ARROW_STEP: Record<string, number | undefined> = {
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 (
<div className="w-[268px] p-3">
@ -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 !== '' && (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span className="cursor-help text-text-secondary" aria-hidden="true">
<CircleHelp className="h-3.5 w-3.5" />
</span>
{/* 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. */}
<button
type="button"
aria-label={localize('com_ui_more_info')}
className="rounded-full text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<CircleHelp className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="top" className="w-72 text-sm">

View file

@ -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<string> = 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<PaletteSection, TranslationKeys> = {
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<HTMLInputElement>(null);
const listRef = useRef<List>(null);
const listBodyRef = useRef<HTMLDivElement>(null);
const disclosureRef = useRef<HTMLButtonElement>(null);
const setLift = useSetRecoilState(store.composerLiftFamily(index));
const { ref: popoverRef, height: popupHeight } = useElementSize<HTMLDivElement>();
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<PaletteRow[]>(() => {
/* 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<PaletteSection, PaletteEntry[]> = { 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<HTMLElement>('[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({
<input
ref={inputRef}
role="combobox"
aria-expanded
aria-expanded={rows.length > 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',

View file

@ -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 ? (
<button
ref={gripRef}
type="button"
data-testid="queued-message-grip"
aria-label={localize('com_ui_queue_reorder', {
0: String(index + 1),
1: String(total),
})}
/* A handle announces what it is but not how to work it, and the keys
are the only way through it without a pointer. */
aria-describedby={REORDER_HINT_ID}
onKeyDown={(event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
return;
}
/* Both keys scroll the rail's container otherwise, which would
chase the row the press just moved. */
event.preventDefault();
move(event.key === 'ArrowUp' ? -1 : 1);
}}
className={cn(
ICON_BTN,
'cursor-grab p-0.5 active:cursor-grabbing',
canDrag && 'touch-none',
)}
>
<GripVertical className="h-4 w-4" aria-hidden="true" />
</button>
) : (
<Clock className="h-3.5 w-3.5 shrink-0 text-text-secondary" aria-hidden="true" />
)}
<button
ref={gripRef}
type="button"
data-testid="queued-message-grip"
/* Kept even when there is nowhere to move to, rather than swapped for
an icon: a queue that drains to one message would otherwise unmount
the handle a keyboard user was holding, dropping focus to the top of
the page. */
aria-disabled={!reorderable}
aria-label={localize('com_ui_queue_reorder', {
0: String(index + 1),
1: String(total),
})}
/* A handle announces what it is but not how to work it, and the keys
are the only way through it without a pointer. */
aria-describedby={reorderable ? REORDER_HINT_ID : undefined}
onKeyDown={(event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
return;
}
/* Both keys scroll the rail's container otherwise, which would
chase the row the press just moved. */
event.preventDefault();
move(event.key === 'ArrowUp' ? -1 : 1);
}}
className={cn(
ICON_BTN,
'p-0.5',
reorderable ? 'cursor-grab active:cursor-grabbing' : 'cursor-default opacity-40',
canDrag && reorderable && 'touch-none',
)}
>
<GripVertical className="h-4 w-4" aria-hidden="true" />
</button>
<span className="min-w-0 flex-1 truncate text-text-primary" title={message.text}>
{message.text}
</span>
@ -259,27 +261,32 @@ function Queue({ steering, conversationId, onEditToComposer, onRestoreToComposer
}
return (
<div
role="list"
aria-label={localize('com_ui_queued_messages')}
data-testid="composer-queue"
/* Inset and only rounded on top: the rail reads as paper tucked behind
the composer rather than a second composer stacked on it. */
className="mx-3 flex flex-col overflow-hidden rounded-t-2xl border border-b-0 border-border-light bg-surface-secondary"
>
{queued.map((message: QueuedMessage, index: number) => (
<QueueRow
key={message.id}
message={message}
index={index}
total={queued.length}
steering={steering}
conversationId={conversationId}
onEditToComposer={onEditToComposer}
onRestoreToComposer={onRestoreToComposer}
onAnnounce={setAnnouncement}
/>
))}
/* Inset and only rounded on top: the rail reads as paper tucked behind
the composer rather than a second composer stacked on it.
The rows are the list; the hint and the live region are not items, and a
list that owns them reports the wrong count. */
<div className="mx-3 overflow-hidden rounded-t-2xl border border-b-0 border-border-light bg-surface-secondary">
<div
role="list"
aria-label={localize('com_ui_queued_messages')}
data-testid="composer-queue"
className="flex flex-col"
>
{queued.map((message: QueuedMessage, index: number) => (
<QueueRow
key={message.id}
message={message}
index={index}
total={queued.length}
steering={steering}
conversationId={conversationId}
onEditToComposer={onEditToComposer}
onRestoreToComposer={onRestoreToComposer}
onAnnounce={setAnnouncement}
/>
))}
</div>
{queued.length > 1 && (
<span id={REORDER_HINT_ID} className="sr-only">
{localize('com_ui_queue_reorder_hint')}

View file

@ -5,6 +5,7 @@ import { TooltipAnchor } from '@librechat/client';
import type { SettingDefinition, TConversation } from 'librechat-data-provider';
import Effort, { AUTO_VALUES, resolveEffortLabel } from './Effort';
import useThinkingSetting from '~/hooks/Input/useThinkingSetting';
import useReducedMotion from '~/hooks/Generic/useReducedMotion';
import { useSetIndexOptions, useLocalize } from '~/hooks';
import { useChatContext } from '~/Providers';
import { cn } from '~/utils';
@ -22,6 +23,7 @@ function ThinkingControl({
conversation: TConversation | null;
}) {
const localize = useLocalize();
const reducedMotion = useReducedMotion();
/* Ariakit owns the open state rather than a controlled `open`/`setOpen` pair:
with the controlled form, hide-on-interact-outside fired on mousedown and
the disclosure's own click re-opened it, so a second click never closed the
@ -103,7 +105,9 @@ function ThinkingControl({
className="relative block overflow-hidden ease-out"
style={{
width: slotWidth,
transition: `width ${RESIZE_MS}ms ${EASE}`,
/* Written inline, so a stylesheet's reduced-motion rule cannot
reach it: the label takes its new width at once instead. */
transition: `width ${reducedMotion ? 0 : RESIZE_MS}ms ${EASE}`,
}}
>
<span

View file

@ -1,14 +1,26 @@
import { memo } from 'react';
import useAudioLevels from '~/hooks/Input/useAudioLevels';
import { cn } from '~/utils';
/** Bars never collapse to nothing, so silence still reads as a live line. */
const MIN_BAR = 0.1;
interface WaveformProps {
/** Whether the microphone is running; the levels are sampled from here. */
active: boolean;
className?: string;
}
/**
* Live microphone trace. Spreads a fixed bar count across whatever width it is
* given, so it fills its container rather than overflowing and being clipped.
*
* Samples the microphone itself rather than being handed the levels: at ~18
* samples a second, holding them any higher up re-rendered the whole composer,
* and every tool and skill row with it, to move these bars.
*/
function Waveform({ levels, className }: { levels: number[]; className?: string }) {
function Waveform({ active, className }: WaveformProps) {
const levels = useAudioLevels(active);
return (
<div
aria-hidden="true"
@ -18,7 +30,7 @@ function Waveform({ levels, className }: { levels: number[]; className?: string
<span
key={index}
style={{ height: `${Math.max(MIN_BAR, level) * 100}%` }}
className="bg-text-primary/70 w-[3px] shrink-0 rounded-full transition-[height] duration-100 ease-out"
className="bg-text-primary/70 w-[3px] shrink-0 rounded-full transition-[height] duration-100 ease-out motion-reduce:transition-none"
/>
))}
</div>

View file

@ -123,19 +123,19 @@ function renderPalette(over: { canAttach?: boolean; entries?: PaletteEntry[] } =
return view;
}
/** Row labels in list order, headers included, as the user reads them. */
/** Row labels in list order, headers included, as the user reads them. The
* list's own containers are presentational too, so rows are found by the
* identity attribute rather than by role alone. */
const rows = () =>
Array.from(
document.querySelectorAll(
'#composer-palette-list [role="option"], #composer-palette-list [role="presentation"]',
),
).map((row) => row.textContent?.trim() ?? '');
Array.from(document.querySelectorAll('#composer-palette-list [data-row-key]')).map(
(row) => row.textContent?.trim() ?? '',
);
/** Just the section headers, which is what carries the order. */
const headers = () =>
Array.from(document.querySelectorAll('#composer-palette-list [role="presentation"]')).map(
(row) => row.textContent?.trim() ?? '',
);
Array.from(
document.querySelectorAll<HTMLElement>('#composer-palette-list [data-row-key^="h:"]'),
).map((row) => row.textContent?.trim() ?? '');
/** Row identities in list order, which is what the model actually decides. */
const keys = () =>
@ -262,7 +262,46 @@ describe('Palette', () => {
it('starts on the first row that can be chosen, never on a header', () => {
renderPalette();
const input = screen.getByTestId('composer-palette-search');
expect(input.getAttribute('aria-activedescendant')).toBe('palette-row-1');
expect(input.getAttribute('aria-activedescendant')).toBe('palette-row-local:provider');
});
/* An id that names nothing is the failure mode here: screen readers lose
the active option entirely and announce nothing in its place. */
it('always points at a row that is in the document', () => {
renderPalette();
const input = screen.getByTestId('composer-palette-search');
const pointsAtSomething = () => {
const id = input.getAttribute('aria-activedescendant');
return id != null && document.getElementById(id) != null;
};
expect(pointsAtSomething()).toBe(true);
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(pointsAtSomething()).toBe(true);
search('code');
expect(pointsAtSomething()).toBe(true);
});
it('keeps the highlight on a row that moved rather than on its old position', () => {
mockFavoriteKeys = [];
renderPalette({ canAttach: false });
const input = screen.getByTestId('composer-palette-search');
fireEvent.keyDown(input, { key: 'ArrowDown' });
const moved = input.getAttribute('aria-activedescendant');
expect(moved).toBe('palette-row-execute_code');
/* Starring it lifts the row into favourites without changing the row
count, which is exactly the case a positional highlight got wrong. */
mockFavoriteKeys = ['execute_code'];
fireEvent.keyDown(input, { key: 'd', metaKey: true });
expect(input.getAttribute('aria-activedescendant')).toBe(moved);
});
it('leaves the list alone when the pointer moves over it', () => {
renderPalette();
const list = document.querySelector('.ReactVirtualized__Grid');
expect(list?.getAttribute('tabindex')).toBe('-1');
expect(list?.getAttribute('role')).toBe('presentation');
expect(list?.getAttribute('aria-label')).toBe('');
});
it('steps over headers on the way down', () => {

View file

@ -44,7 +44,14 @@ const queued = (over: Partial<QueuedMessage> = {}): QueuedMessage =>
...over,
}) as QueuedMessage;
function renderQueue(items: QueuedMessage[], steeringOverride: SteeringControls = steering) {
function renderQueue(
items: QueuedMessage[],
steeringOverride: SteeringControls = steering,
handlers: {
onEditToComposer?: jest.Mock;
onRestoreToComposer?: jest.Mock;
} = {},
) {
return render(
<RecoilRoot initializeState={({ set }) => set(store.queuedMessagesByConvoId(CONVO_ID), items)}>
{/* Mirrors `App`, which mounts the provider around the whole tree. */}
@ -52,8 +59,8 @@ function renderQueue(items: QueuedMessage[], steeringOverride: SteeringControls
<Queue
steering={steeringOverride}
conversationId={CONVO_ID}
onEditToComposer={jest.fn()}
onRestoreToComposer={jest.fn()}
onEditToComposer={handlers.onEditToComposer ?? jest.fn()}
onRestoreToComposer={handlers.onRestoreToComposer ?? jest.fn()}
/>
</DndProvider>
</RecoilRoot>,
@ -80,10 +87,12 @@ describe('Queue', () => {
expect(firstRow.queryByLabelText('com_ui_more_options')).not.toBeInTheDocument();
});
it('sends a row now', () => {
renderQueue([queued()]);
fireEvent.click(screen.getByText('com_ui_send_now'));
expect(mockSendQueuedNow).toHaveBeenCalledTimes(1);
it('sends the row that was clicked, not the first one', () => {
renderQueue([queued({ id: 'q1' }), queued({ id: 'q2', text: 'the second one' })]);
fireEvent.click(screen.getAllByText('com_ui_send_now')[1]);
expect(mockSendQueuedNow).toHaveBeenCalledWith(
expect.objectContaining({ id: 'q2', text: 'the second one' }),
);
});
it('disables send now while the run is paused on approval', () => {
@ -122,9 +131,62 @@ describe('Queue', () => {
expect(screen.getByRole('status')).toHaveTextContent('com_ui_queue_moved:2');
});
it('offers no handle when the only message has nowhere to go', () => {
/* Swapping the handle out from under a keyboard user is how focus gets
dropped to the top of the page when a drain shrinks the queue. */
it('keeps the handle when the only message has nowhere to go, and refuses to move it', () => {
renderQueue([queued()]);
expect(screen.queryByTestId('queued-message-grip')).not.toBeInTheDocument();
const grip = screen.getByTestId('queued-message-grip');
expect(grip).toHaveAttribute('aria-disabled', 'true');
fireEvent.keyDown(grip, { key: 'ArrowDown' });
fireEvent.keyDown(grip, { key: 'ArrowUp' });
expect(mockReorderQueued).not.toHaveBeenCalled();
});
it('keeps the live region and the hint out of the list itself', () => {
renderQueue([queued({ id: 'q1' }), queued({ id: 'q2' })]);
const list = screen.getByTestId('composer-queue');
expect(within(list).queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toBeInTheDocument();
/* Every child of the list is one of its items. */
for (const child of Array.from(list.children)) {
expect(child).toHaveAttribute('role', 'listitem');
}
});
it('returns a trashed message to the composer before dropping it', () => {
const onRestore = jest.fn().mockReturnValue(true);
renderQueue([queued({ id: 'q1', files: [{ file_id: 'f1' }] as never })], steering, {
onRestoreToComposer: onRestore,
});
fireEvent.click(screen.getByLabelText('com_ui_remove_queued'));
expect(onRestore).toHaveBeenCalledWith(
'follow up on this',
[{ file_id: 'f1' }],
{ quotes: [], manualSkills: [] },
CONVO_ID,
);
expect(mockRemoveQueued).toHaveBeenCalledWith('q1');
});
it('drops the message even when the composer refuses to take it back', () => {
const onRestore = jest.fn().mockReturnValue(false);
renderQueue([queued({ id: 'q1' })], steering, { onRestoreToComposer: onRestore });
fireEvent.click(screen.getByLabelText('com_ui_remove_queued'));
expect(mockRemoveQueued).toHaveBeenCalledWith('q1');
});
it('hands the whole message to the composer to edit', () => {
const onEdit = jest.fn();
renderQueue([queued({ id: 'q1', quotes: ['a quote'], manualSkills: ['writer'] })], steering, {
onEditToComposer: onEdit,
});
fireEvent.click(screen.getByLabelText('com_ui_edit_message'));
expect(onEdit).toHaveBeenCalledWith('follow up on this', [], {
quotes: ['a quote'],
manualSkills: ['writer'],
});
expect(mockRemoveQueued).toHaveBeenCalledWith('q1');
});
it('shows an attachment count when files ride along', () => {

View file

@ -1,3 +1,4 @@
export * from './useLazyEffect';
export { default as useShiftKey } from './useShiftKey';
export { default as useElementSize } from './useElementSize';
export { default as useReducedMotion } from './useReducedMotion';

View file

@ -0,0 +1,27 @@
import { useState, useEffect } from 'react';
const QUERY = '(prefers-reduced-motion: reduce)';
/**
* Whether the person using this has asked for less movement.
*
* Stylesheets can answer that themselves, so this is for the motion they cannot
* reach: transitions written as inline styles, and animations played from
* script. It follows the setting rather than reading it once, since it can be
* changed while the page is open.
*/
export default function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() => typeof window !== 'undefined' && window.matchMedia(QUERY).matches,
);
useEffect(() => {
const media = window.matchMedia(QUERY);
const update = () => setReduced(media.matches);
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
return reduced;
}

View file

@ -126,7 +126,12 @@ export default function useAttachItems({
* Allow defining agent capabilities on a per-endpoint basis
* Use definition for agents endpoint for ephemeral agents
* */
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
/* Destructured rather than held whole: the hook returns a fresh object every
render, and depending on it rebuilt every destination, every icon and every
closure below on each keystroke. */
const { contextEnabled, fileSearchEnabled, codeEnabled } = useAgentCapabilities(
agentsConfig?.capabilities ?? defaultAgentCapabilities,
);
const { tools, provider } = useAgentToolPermissions(agentId, ephemeralAgent);
/* The same allowances the drag-and-drop router resolves, so a file reaches
the same destinations whether it is dropped on the composer or picked
@ -224,7 +229,7 @@ export default function useAttachItems({
});
}
if (capabilities.contextEnabled) {
if (contextEnabled) {
items.push({
id: `${prefix}:context`,
label: localize('com_ui_upload_ocr_text'),
@ -236,7 +241,7 @@ export default function useAttachItems({
});
}
if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) {
if (fileSearchEnabled && fileSearchAllowedByAgent) {
items.push({
id: `${prefix}:file_search`,
label: localize('com_ui_upload_file_search'),
@ -249,7 +254,7 @@ export default function useAttachItems({
});
}
if (capabilities.codeEnabled && codeAllowedByAgent) {
if (codeEnabled && codeAllowedByAgent) {
items.push({
id: `${prefix}:execute_code`,
label: localize('com_ui_upload_code_environment'),
@ -287,7 +292,9 @@ export default function useAttachItems({
endpoint,
provider,
endpointType,
capabilities,
codeEnabled,
contextEnabled,
fileSearchEnabled,
useResponsesApi,
handleUploadClick,
setEphemeralAgent,

View file

@ -1,7 +1,6 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { useToastContext } from '@librechat/client';
import type { TAskFunction } from '~/common';
import useAudioLevels from '~/hooks/Input/useAudioLevels';
import useGetAudioSettings from './useGetAudioSettings';
import { useChatFormContext } from '~/Providers';
import useSpeechToText from './useSpeechToText';
@ -20,7 +19,6 @@ type StopMode = 'compose' | 'send' | 'cancel';
export interface Dictation {
active: boolean;
transcribing: boolean;
levels: number[];
elapsed: number;
start: () => void;
/** Drop the take and restore whatever draft was there before. */
@ -125,7 +123,6 @@ export default function useDictation({
);
const active = isListening === true;
const levels = useAudioLevels(active);
/* Bridges the gap between the recorder being told to stop and the upload
starting: `isListening` clears synchronously, while the recorder's own
@ -188,7 +185,6 @@ export default function useDictation({
return {
active,
transcribing: isLoading === true || settling,
levels,
elapsed,
start,
cancel,

View file

@ -111,9 +111,13 @@ function useAllSkills(enabled: boolean): TSkillSummary[] {
export default function usePaletteEntries({
conversationId,
agentId,
enabled = true,
}: {
conversationId: string;
agentId?: string | null;
/** Endpoints without a tool row discard these, so there is nothing to fetch
* or build for them. */
enabled?: boolean;
}): PaletteEntry[] {
const localize = useLocalize();
const context = useBadgeRowContext();
@ -156,7 +160,7 @@ export default function usePaletteEntries({
});
const canUseMemory = useHasMemoryAccess();
const skillsListable = canUseSkills && skillsEnabled;
const skillsListable = enabled && canUseSkills && skillsEnabled;
const allSkills = useAllSkills(skillsListable);
/* Mirrors backend `resolveAgentScopedSkillIds`: ephemeral agents see the full
@ -194,7 +198,7 @@ export default function usePaletteEntries({
return useMemo(() => {
const entries: PaletteEntry[] = [];
if (!context) {
if (!context || !enabled) {
return entries;
}
@ -381,6 +385,7 @@ export default function usePaletteEntries({
return entries;
}, [
context,
enabled,
localize,
canUseMcp,
canRunCode,

View file

@ -140,7 +140,11 @@ const useSpeechToTextExternal = (
};
const monitorSilence = (stream: MediaStream, stopRecording: () => void) => {
/* Held so it can be closed again: without this the ref below is never
assigned, its guard is always true, and every take leaves another audio
context open until the browser refuses to grant one. */
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
const audioStreamSource = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.minDecibels = minDecibels;
@ -209,6 +213,15 @@ const useSpeechToTextExternal = (
}
};
/** Releases the silence monitor's audio graph; safe to call more than once. */
const closeAudioContext = () => {
const context = audioContextRef.current;
audioContextRef.current = null;
if (context != null && context.state !== 'closed') {
void context.close().catch(() => undefined);
}
};
const stopRecording = () => {
if (!mediaRecorderRef.current) {
return;
@ -224,6 +237,7 @@ const useSpeechToTextExternal = (
window.cancelAnimationFrame(animationFrameIdRef.current);
animationFrameIdRef.current = null;
}
closeAudioContext();
setIsListening(false);
} else {
@ -273,6 +287,7 @@ const useSpeechToTextExternal = (
window.cancelAnimationFrame(animationFrameIdRef.current);
animationFrameIdRef.current = null;
}
closeAudioContext();
setIsListening(false);
};
@ -307,6 +322,19 @@ const useSpeechToTextExternal = (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isListening]);
/* Navigating away mid-take ends neither path above, and the audio graph
would outlive the page that opened it. */
useEffect(
() => () => {
const context = audioContextRef.current;
audioContextRef.current = null;
if (context != null && context.state !== 'closed') {
void context.close().catch(() => undefined);
}
},
[],
);
return {
isListening,
externalStopRecording,