mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
parent
608d6c06cd
commit
406dede084
17 changed files with 492 additions and 162 deletions
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
27
client/src/hooks/Generic/useReducedMotion.ts
Normal file
27
client/src/hooks/Generic/useReducedMotion.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue