fix: repair schedule dialog interaction and rework its form

The agent picker was unusable: ControlCombobox portals its popover to the
body by default, which lands it outside the dialog's Radix focus trap. Clicks
passed through it, it could not be tabbed into, and the trap fighting Ariakit
for focus locked the page up on selection. The prop is documented for exactly
this case — pass `portal={false}` and give the dialog `overflow-visible`, as
ProjectButton already does. The time and day dropdowns defaulted the same way.

Alongside that:

- Extract the agent builder's instructions editor (special-variable menu plus
  expand-to-fullscreen) into a controlled `VariableEditor` and use it for the
  schedule prompt. Insertions now route through `onChange`, so react-hook-form
  sees them — the schedule PATCH is built from `dirtyFields`, and a `setValue`
  that skipped dirty tracking would have dropped an inserted variable silently.
- Replace the hand-rolled frequency buttons with the shared `Radio`. They marked
  the selection with `bg-surface-hover` on an outline button whose hover is the
  same token, so the selected option was indistinguishable from a hovered one;
  `Radio` is also a real radiogroup rather than four `aria-pressed` toggles.
- Wrap the fields in a real `<form>` and associate the footer button by id, so
  Enter submits. Group the frequency, day and time controls in fieldsets.
- Add placeholders for name and prompt, match the textarea fill to the other
  fields, and label the hourly case as minutes past the hour.
- Widen the dialog to `md:max-w-3xl` and pair name with agent so the form fits
  without scrolling on desktop.
- Move scheduled chats below skills and above prompts in the side nav.

The new dialog spec fails when the portal fix is reverted.
This commit is contained in:
Danny Avila 2026-08-17 15:47:37 -04:00
parent ecddfa0376
commit d6c922f52a
8 changed files with 579 additions and 349 deletions

View file

@ -1,185 +1,42 @@
import { useState, useId } from 'react';
import * as Menu from '@ariakit/react/menu';
import { PlusCircle, Maximize2 } from 'lucide-react';
import { specialVariables } from 'librechat-data-provider';
import { Controller, useFormContext } from 'react-hook-form';
import {
Label,
Button,
OGDialog,
Textarea,
DropdownPopup,
OGDialogClose,
TooltipAnchor,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
} from '@librechat/client';
import type { TSpecialVarLabel } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { VariableEditor } from '~/components/Variables';
import { useLocalize } from '~/hooks';
interface VariableOption {
label: TSpecialVarLabel;
value: string;
}
const variableOptions: VariableOption[] = Object.keys(specialVariables).map((key) => ({
label: `com_ui_special_var_${key}` as TSpecialVarLabel,
value: `{{${key}}}`,
}));
export default function Instructions() {
const menuId = useId();
const dialogMenuId = useId();
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control, setValue, getValues } = methods;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isDialogMenuOpen, setIsDialogMenuOpen] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleAddVariable = (label: TSpecialVarLabel, value: string) => {
const currentInstructions = getValues('instructions') || '';
const spacer = currentInstructions.length > 0 ? '\n' : '';
const prefix = localize(label);
setValue('instructions', currentInstructions + spacer + prefix + ': ' + value);
setIsMenuOpen(false);
setIsDialogMenuOpen(false);
};
const variableItems = variableOptions.map((option) => ({
label: localize(option.label) || option.label,
onClick: () => handleAddVariable(option.label, option.value),
}));
const { control } = useFormContext<AgentForm>();
return (
<div className="mb-3 flex flex-col">
<div className="mb-1 flex items-center justify-between">
<Label
className="block text-[11px] font-medium uppercase tracking-wide text-text-secondary"
htmlFor="instructions"
>
{localize('com_ui_instructions')}
</Label>
<div className="flex items-center gap-0.5">
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton
id="variables-menu-button"
aria-label={localize('com_ui_variables')}
title={localize('com_ui_variables')}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-text-secondary transition-colors hover:bg-surface-secondary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<PlusCircle className="h-4 w-4" strokeWidth={1.75} aria-hidden={true} />
</Menu.MenuButton>
}
items={variableItems}
menuId={menuId}
className="pointer-events-auto z-30"
/>
<TooltipAnchor
description={localize('com_ui_expand_editor')}
render={
<Button
variant="ghost"
onClick={() => setIsDialogOpen(true)}
aria-label={localize('com_ui_expand_editor')}
className="h-7 w-7 p-0 text-text-secondary hover:bg-surface-secondary hover:text-text-primary"
>
<Maximize2 className="h-4 w-4" strokeWidth={1.75} aria-hidden={true} />
</Button>
}
<Controller
name="instructions"
control={control}
render={({ field, fieldState: { error } }) => (
<div className="mb-3 flex flex-col">
<VariableEditor
id="instructions"
label={localize('com_ui_instructions')}
value={field.value ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder={localize('com_agents_instructions_placeholder')}
className="min-h-[88px] resize-y"
labelClassName="block text-[11px] font-medium uppercase tracking-wide text-text-secondary"
rows={3}
required={true}
invalid={error != null}
/>
{error && (
<span
className="mt-1 text-xs text-text-destructive transition duration-300 ease-in-out"
role="alert"
>
{localize('com_ui_field_required')}
</span>
)}
</div>
</div>
<Controller
name="instructions"
control={control}
render={({ field, fieldState: { error } }) => (
<>
<Textarea
{...field}
value={field.value ?? ''}
className="min-h-[88px] resize-y"
id="instructions"
placeholder={localize('com_agents_instructions_placeholder')}
rows={3}
aria-label={localize('com_ui_instructions')}
aria-required="true"
aria-invalid={error ? 'true' : 'false'}
/>
{error && (
<span
className="mt-1 text-xs text-text-destructive transition duration-300 ease-in-out"
role="alert"
>
{localize('com_ui_field_required')}
</span>
)}
</>
)}
/>
<OGDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<OGDialogContent
className="flex h-[85vh] max-h-[85vh] w-11/12 max-w-6xl flex-col gap-4 p-6"
showCloseButton={false}
>
<OGDialogHeader className="mb-2 pr-14">
<OGDialogTitle className="text-left text-2xl font-semibold">
{localize('com_ui_instructions')}
</OGDialogTitle>
</OGDialogHeader>
<Controller
name="instructions"
control={control}
render={({ field }) => (
<Textarea
{...field}
value={field.value ?? ''}
className="min-h-0 flex-1 resize-none text-base leading-relaxed"
placeholder={localize('com_agents_instructions_placeholder')}
aria-label={localize('com_ui_instructions')}
/>
)}
/>
<div className="flex items-center justify-between">
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isDialogMenuOpen}
setIsOpen={setIsDialogMenuOpen}
trigger={
<Menu.MenuButton
id="variables-menu-button-dialog"
render={
<Button variant="outline" className="gap-1.5">
<PlusCircle className="h-4 w-4" strokeWidth={1.75} aria-hidden={true} />
{localize('com_ui_variables')}
</Button>
}
/>
}
items={variableItems}
menuId={dialogMenuId}
className="pointer-events-auto z-[200]"
/>
<OGDialogClose asChild>
<Button>{localize('com_ui_done')}</Button>
</OGDialogClose>
</div>
</OGDialogContent>
</OGDialog>
</div>
)}
/>
);
}

View file

@ -6,10 +6,10 @@ import { PermissionBits, scheduleFrequencies } from 'librechat-data-provider';
import {
Input,
Label,
Radio,
Button,
FieldMessage,
Spinner,
Textarea,
Dropdown,
OGDialog,
ControlCombobox,
@ -30,6 +30,7 @@ import {
useUpdateScheduleMutation,
} from '~/data-provider';
import { to12Hour, to24Hour, describeCadence, formatScheduleDay } from './cadence';
import { VariableEditor } from '~/components/Variables';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -60,6 +61,11 @@ const FREQUENCY_LABELS: Record<ScheduleFrequency, TranslationKeys> = {
const BASE_MINUTES = [0, 15, 30, 45];
const FORM_ID = 'schedule-form';
/** Matches the Input primitive so every control in the form reads as one field set. */
const FIELD_CLASS = 'h-10 w-full rounded-lg border border-border-light bg-transparent';
const getDefaultValues = (schedule?: TSchedule): ScheduleFormValues => {
if (!schedule) {
return {
@ -152,6 +158,15 @@ export default function ScheduleDialog({
[agents],
);
const frequencyOptions = useMemo(
() =>
scheduleFrequencies.map((value) => ({
value,
label: localize(FREQUENCY_LABELS[value]),
})),
[localize],
);
const hourOptions = useMemo(
() => Array.from({ length: 12 }, (_, index) => String(index + 1)),
[],
@ -322,193 +337,235 @@ export default function ScheduleDialog({
<OGDialogTemplate
title={localize(schedule ? 'com_ui_schedule_edit' : 'com_ui_schedule_new')}
showCloseButton={false}
className="w-11/12 md:max-w-lg"
// The agent and time popovers cannot portal out of a focus-trapping dialog
// (see below), so `overflow-visible` keeps them from being clipped. Only from
// `md` up: the two-column layout fits well inside 90vh there, while narrow
// viewports keep the template's scrolling so the footer stays reachable.
className="w-11/12 md:max-w-3xl md:overflow-visible"
main={
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="schedule-name" className="text-sm font-medium text-text-primary">
{localize('com_ui_name')}
</Label>
<Input
id="schedule-name"
className="w-full"
aria-invalid={errors.name != null}
aria-describedby="schedule-name-message"
{...register('name', { required: localize('com_ui_field_required') })}
/>
<FieldMessage id="schedule-name-message" message={errors.name?.message} />
<form id={FORM_ID} onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="schedule-name" className="text-sm font-medium text-text-primary">
{localize('com_ui_name')}
</Label>
<Input
id="schedule-name"
className="w-full"
placeholder={localize('com_ui_schedule_name_placeholder')}
aria-invalid={errors.name != null}
aria-describedby="schedule-name-message"
{...register('name', { required: localize('com_ui_field_required') })}
/>
<FieldMessage id="schedule-name-message" message={errors.name?.message} />
</div>
<div className="space-y-2">
<Label htmlFor="schedule-agent" className="text-sm font-medium text-text-primary">
{localize('com_ui_agent')}
</Label>
<Controller
name="agent_id"
control={control}
rules={{ required: localize('com_ui_field_required') }}
render={({ field }) => (
<ControlCombobox
selectedValue={field.value}
displayValue={
agentItems.find((item) => item.value === field.value)?.label ?? ''
}
selectPlaceholder={localize('com_ui_select_agent')}
searchPlaceholder={localize('com_agents_search_name')}
setValue={field.onChange}
onBlur={field.onBlur}
items={agentItems}
ariaLabel={localize('com_ui_agent')}
ariaInvalid={errors.agent_id != null}
ariaDescribedBy="schedule-agent-message"
selectId="schedule-agent"
isCollapsed={false}
showCarat={true}
placement="bottom-start"
// Radix traps focus inside the dialog, so a popover portaled to
// the body cannot be clicked, tabbed into, or typed in — and the
// trap fighting Ariakit for focus locks the page up.
portal={false}
matchTriggerWidth={true}
containerClassName="w-full px-0"
className={cn(
FIELD_CLASS,
'justify-start px-3 text-sm hover:bg-surface-hover',
)}
/>
)}
/>
<FieldMessage id="schedule-agent-message" message={errors.agent_id?.message} />
<p className="text-xs text-text-secondary">
{localize('com_ui_schedule_target_new_chat')}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="schedule-prompt" className="text-sm font-medium text-text-primary">
{localize('com_ui_prompt')}
</Label>
<Textarea
id="schedule-prompt"
rows={4}
className="min-h-[100px] w-full resize-none"
aria-invalid={errors.prompt != null}
aria-describedby="schedule-prompt-message"
{...register('prompt', { required: localize('com_ui_field_required') })}
/>
<FieldMessage id="schedule-prompt-message" message={errors.prompt?.message} />
</div>
<div className="space-y-2">
<Label htmlFor="schedule-agent" className="text-sm font-medium text-text-primary">
{localize('com_ui_agent')}
</Label>
<Controller
name="agent_id"
control={control}
rules={{ required: localize('com_ui_field_required') }}
render={({ field }) => (
<ControlCombobox
selectedValue={field.value}
displayValue={
agentItems.find((item) => item.value === field.value)?.label ?? ''
}
selectPlaceholder={localize('com_ui_select_agent')}
searchPlaceholder={localize('com_agents_search_name')}
setValue={field.onChange}
<Controller
name="prompt"
control={control}
rules={{ required: localize('com_ui_field_required') }}
render={({ field }) => (
<div className="space-y-2">
<VariableEditor
id="schedule-prompt"
label={localize('com_ui_prompt')}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
items={agentItems}
ariaLabel={localize('com_ui_agent')}
ariaInvalid={errors.agent_id != null}
ariaDescribedBy="schedule-agent-message"
selectId="schedule-agent"
isCollapsed={false}
showCarat={true}
containerClassName="px-0"
className="h-9 w-full rounded-md border border-border-light bg-transparent"
inputRef={field.ref}
placeholder={localize('com_ui_schedule_prompt_placeholder')}
className="min-h-[120px] resize-none bg-transparent"
labelClassName="text-sm font-medium text-text-primary"
rows={4}
required={true}
invalid={errors.prompt != null}
describedBy="schedule-prompt-message"
portal={false}
/>
)}
/>
<FieldMessage id="schedule-agent-message" message={errors.agent_id?.message} />
<p className="text-xs text-text-secondary">
{localize('com_ui_schedule_target_new_chat')}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-text-primary">
{localize('com_ui_schedule_frequency')}
</Label>
<FieldMessage id="schedule-prompt-message" message={errors.prompt?.message} />
</div>
)}
/>
<fieldset className="space-y-2">
<legend>
<Label
id="schedule-frequency-label"
className="text-sm font-medium text-text-primary"
>
{localize('com_ui_schedule_frequency')}
</Label>
</legend>
<Controller
name="frequency"
control={control}
render={({ field }) => (
<div
className="grid grid-cols-2 gap-1 sm:grid-cols-4"
role="group"
aria-label={localize('com_ui_schedule_frequency')}
>
{scheduleFrequencies.map((frequency) => (
<Button
key={frequency}
type="button"
size="sm"
variant="outline"
aria-pressed={field.value === frequency}
className={cn(
'flex-1',
field.value === frequency
? 'bg-surface-hover hover:bg-surface-hover'
: 'bg-transparent',
)}
onClick={() => field.onChange(frequency)}
>
{localize(FREQUENCY_LABELS[frequency])}
</Button>
))}
</div>
<Radio
options={frequencyOptions}
value={field.value}
onChange={(value) => field.onChange(value as ScheduleFrequency)}
fullWidth
aria-labelledby="schedule-frequency-label"
/>
)}
/>
</div>
{frequency === 'weekly' && (
<div className="space-y-2">
<Label className="text-sm font-medium text-text-primary">
{localize('com_ui_schedule_day')}
</Label>
<Controller
name="dayOfWeek"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={dayOptions}
className="w-full"
ariaLabel={localize('com_ui_schedule_day')}
testId="schedule-day-select"
/>
)}
/>
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-medium text-text-primary">
{localize('com_ui_schedule_time')}
</Label>
<div
className={cn(
'grid gap-2',
frequency === 'hourly' ? 'grid-cols-1' : 'grid-cols-2 sm:grid-cols-3',
)}
>
{frequency !== 'hourly' && (
</fieldset>
<div className="grid gap-4 md:grid-cols-2">
{frequency === 'weekly' && (
<fieldset className="space-y-2">
<legend>
<Label
id="schedule-day-label"
className="text-sm font-medium text-text-primary"
>
{localize('com_ui_schedule_day')}
</Label>
</legend>
<Controller
name="hour12"
name="dayOfWeek"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={hourOptions}
className="w-full"
ariaLabel={localize('com_ui_schedule_hour')}
testId="schedule-hour-select"
options={dayOptions}
className={FIELD_CLASS}
portal={false}
aria-labelledby="schedule-day-label"
testId="schedule-day-select"
/>
)}
/>
)}
<Controller
name="minute"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={minuteOptions}
className="w-full"
ariaLabel={localize('com_ui_schedule_minute')}
testId="schedule-minute-select"
</fieldset>
)}
<fieldset className="space-y-2">
<legend>
<Label id="schedule-time-label" className="text-sm font-medium text-text-primary">
{localize(
frequency === 'hourly'
? 'com_ui_schedule_minutes_past_hour'
: 'com_ui_schedule_time',
)}
</Label>
</legend>
<div
className={cn(
'grid gap-2',
frequency === 'hourly' ? 'max-w-[8rem] grid-cols-1' : 'grid-cols-3',
)}
>
{frequency !== 'hourly' && (
<Controller
name="hour12"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={hourOptions}
className={FIELD_CLASS}
portal={false}
ariaLabel={localize('com_ui_schedule_hour')}
testId="schedule-hour-select"
/>
)}
/>
)}
/>
{frequency !== 'hourly' && (
<Controller
name="meridiem"
name="minute"
control={control}
render={({ field }) => (
<Dropdown
value={field.value}
onChange={field.onChange}
options={meridiemOptions}
className="w-full"
ariaLabel={localize('com_ui_schedule_meridiem')}
testId="schedule-meridiem-select"
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={minuteOptions}
className={FIELD_CLASS}
portal={false}
ariaLabel={localize('com_ui_schedule_minute')}
testId="schedule-minute-select"
/>
)}
/>
)}
</div>
{frequency !== 'hourly' && (
<Controller
name="meridiem"
control={control}
render={({ field }) => (
<Dropdown
value={field.value}
onChange={field.onChange}
options={meridiemOptions}
className={FIELD_CLASS}
portal={false}
ariaLabel={localize('com_ui_schedule_meridiem')}
testId="schedule-meridiem-select"
/>
)}
/>
)}
</div>
</fieldset>
</div>
<p className="break-words text-sm text-text-secondary">{summary}</p>
</div>
<p
className="break-words rounded-lg bg-surface-secondary px-3 py-2 text-sm text-text-secondary"
data-testid="schedule-summary"
>
{summary}
</p>
</form>
}
buttons={
<Button
type="button"
type="submit"
form={FORM_ID}
variant="submit"
onClick={handleSubmit(onSubmit)}
disabled={isLoading}
aria-label={localize(schedule ? 'com_ui_save' : 'com_ui_create')}
>

View file

@ -0,0 +1,109 @@
import { createElement } from 'react';
import { ToastProvider } from '@librechat/client';
import userEvent from '@testing-library/user-event';
import { render, screen, within } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import ScheduleDialog from '../ScheduleDialog';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
/** `@librechat/client` primitives localize through their own `useLocalize`, so the
* shared `t` has to resolve too — not just the `i18n` this dialog reads for locale. */
jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en-US' } }),
}));
const mockMutate = jest.fn();
jest.mock('~/data-provider', () => ({
useListAgentsQuery: () => ({
data: [
{ id: 'agent-1', name: 'Research Agent' },
{ id: 'agent-2', name: 'Digest Agent' },
],
}),
useCreateScheduleMutation: () => ({ mutate: mockMutate, isLoading: false }),
useUpdateScheduleMutation: () => ({ mutate: mockMutate, isLoading: false }),
}));
const renderDialog = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
function Wrapper({ children }: { children: ReactNode }) {
return createElement(
QueryClientProvider,
{ client: queryClient },
createElement(ToastProvider, null, children),
);
}
return render(<ScheduleDialog open={true} onOpenChange={jest.fn()} />, { wrapper: Wrapper });
};
describe('ScheduleDialog', () => {
afterEach(() => jest.clearAllMocks());
/**
* The agent list must render INSIDE the dialog. Portaled to the body it lands
* outside Radix's focus trap, where it cannot be clicked, tabbed into, or typed
* in — and the trap fighting Ariakit for focus locks the page up.
*/
it('opens the agent list inside the dialog rather than portaling it away', async () => {
const user = userEvent.setup();
renderDialog();
const dialog = screen.getByRole('dialog');
await user.click(within(dialog).getByRole('combobox', { name: 'com_ui_agent' }));
expect(within(dialog).getByPlaceholderText('com_agents_search_name')).toBeInTheDocument();
});
it('exposes frequency as a radiogroup with the default selected', () => {
renderDialog();
const group = screen.getByRole('radiogroup', { name: 'com_ui_schedule_frequency' });
expect(within(group).getByRole('radio', { name: 'com_ui_schedule_daily' })).toBeChecked();
expect(within(group).getByRole('radio', { name: 'com_ui_schedule_weekly' })).not.toBeChecked();
});
it('switches the day picker on only for weekly schedules', async () => {
const user = userEvent.setup();
renderDialog();
expect(screen.queryByTestId('schedule-day-select')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_weekly' }));
expect(screen.getByTestId('schedule-day-select')).toBeInTheDocument();
});
/** The submit button sits in the dialog footer, outside the <form>, so it has to
* reach it by id — that association is what lets Enter submit from a text field. */
it('wires the footer submit button to the form', () => {
renderDialog();
const form = document.getElementById('schedule-form');
expect(form).toBeInstanceOf(HTMLFormElement);
const submit = screen.getByRole('button', { name: 'com_ui_create' });
expect(submit).toHaveAttribute('type', 'submit');
expect(submit).toHaveAttribute('form', 'schedule-form');
});
it('describes both text fields with placeholders', () => {
renderDialog();
expect(screen.getByPlaceholderText('com_ui_schedule_name_placeholder')).toBeInTheDocument();
expect(screen.getByPlaceholderText('com_ui_schedule_prompt_placeholder')).toBeInTheDocument();
});
it('offers the special-variable menu on the prompt field', async () => {
const user = userEvent.setup();
renderDialog();
await user.click(screen.getByRole('button', { name: 'com_ui_variables' }));
expect(
await screen.findByRole('menuitem', { name: 'com_ui_special_var_current_date' }),
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,203 @@
import { useId, useState } from 'react';
import * as Menu from '@ariakit/react/menu';
import { PlusCircle, Maximize2 } from 'lucide-react';
import { specialVariables } from 'librechat-data-provider';
import {
Label,
Button,
OGDialog,
Textarea,
DropdownPopup,
OGDialogClose,
TooltipAnchor,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
} from '@librechat/client';
import type { TSpecialVarLabel } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface VariableOption {
label: TSpecialVarLabel;
value: string;
}
const variableOptions: VariableOption[] = Object.keys(specialVariables).map((key) => ({
label: `com_ui_special_var_${key}` as TSpecialVarLabel,
value: `{{${key}}}`,
}));
interface VariableEditorProps {
id: string;
/** Already-localized heading rendered above the field. */
label: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
inputRef?: React.Ref<HTMLTextAreaElement>;
placeholder?: string;
rows?: number;
required?: boolean;
invalid?: boolean;
describedBy?: string;
className?: string;
labelClassName?: string;
containerClassName?: string;
/**
* Ariakit popovers portal to the body by default, which puts them outside a Radix
* dialog's focus trap. Pass `false` from inside a dialog, and give that dialog
* `overflow-visible` so the inline menu is not clipped.
*/
portal?: boolean;
}
/**
* A textarea for model-facing text with a menu that appends special variables
* (`{{current_date}}` and friends) and an expand-to-fullscreen editor. Controlled so
* each caller binds it to its own form field — insertions go through `onChange`, which
* keeps them visible to react-hook-form's dirty tracking.
*/
export default function VariableEditor({
id,
label,
value,
onChange,
onBlur,
inputRef,
placeholder,
rows = 3,
required,
invalid,
describedBy,
className,
labelClassName,
containerClassName,
portal = true,
}: VariableEditorProps) {
const menuId = useId();
const dialogMenuId = useId();
const localize = useLocalize();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isDialogMenuOpen, setIsDialogMenuOpen] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleAddVariable = (variableLabel: TSpecialVarLabel, variableValue: string) => {
const spacer = value.length > 0 ? '\n' : '';
onChange(value + spacer + localize(variableLabel) + ': ' + variableValue);
setIsMenuOpen(false);
setIsDialogMenuOpen(false);
};
const variableItems = variableOptions.map((option) => ({
label: localize(option.label) || option.label,
onClick: () => handleAddVariable(option.label, option.value),
}));
return (
<div className={cn('flex flex-col', containerClassName)}>
<div className="mb-1 flex items-center justify-between">
<Label className={labelClassName} htmlFor={id}>
{label}
</Label>
<div className="flex items-center gap-0.5">
<DropdownPopup
portal={portal}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton
id={`${id}-variables-menu-button`}
aria-label={localize('com_ui_variables')}
title={localize('com_ui_variables')}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-text-secondary transition-colors hover:bg-surface-secondary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<PlusCircle className="h-4 w-4" strokeWidth={1.75} aria-hidden={true} />
</Menu.MenuButton>
}
items={variableItems}
menuId={menuId}
className="pointer-events-auto z-30"
/>
<TooltipAnchor
description={localize('com_ui_expand_editor')}
render={
<Button
type="button"
variant="ghost"
onClick={() => setIsDialogOpen(true)}
aria-label={localize('com_ui_expand_editor')}
className="h-7 w-7 p-0 text-text-secondary hover:bg-surface-secondary hover:text-text-primary"
>
<Maximize2 className="h-4 w-4" strokeWidth={1.75} aria-hidden={true} />
</Button>
}
/>
</div>
</div>
<Textarea
id={id}
ref={inputRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
className={className}
placeholder={placeholder}
rows={rows}
aria-label={label}
aria-required={required}
aria-invalid={invalid}
aria-describedby={describedBy}
/>
<OGDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<OGDialogContent
className="flex h-[85vh] max-h-[85vh] w-11/12 max-w-6xl flex-col gap-4 p-6"
showCloseButton={false}
>
<OGDialogHeader className="mb-2 pr-14">
<OGDialogTitle className="text-left text-2xl font-semibold">{label}</OGDialogTitle>
</OGDialogHeader>
<Textarea
value={value}
onChange={(event) => onChange(event.target.value)}
className="min-h-0 flex-1 resize-none text-base leading-relaxed"
placeholder={placeholder}
aria-label={label}
/>
<div className="flex items-center justify-between">
<DropdownPopup
portal={portal}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isDialogMenuOpen}
setIsOpen={setIsDialogMenuOpen}
trigger={
<Menu.MenuButton
id={`${id}-variables-menu-button-dialog`}
render={
<Button type="button" variant="outline" className="gap-1.5">
<PlusCircle className="h-4 w-4" strokeWidth={1.75} aria-hidden={true} />
{localize('com_ui_variables')}
</Button>
}
/>
}
items={variableItems}
menuId={dialogMenuId}
className="pointer-events-auto z-[200]"
/>
<OGDialogClose asChild>
<Button type="button">{localize('com_ui_done')}</Button>
</OGDialogClose>
</div>
</OGDialogContent>
</OGDialog>
</div>
);
}

View file

@ -0,0 +1 @@
export { default as VariableEditor } from './Editor';

View file

@ -146,26 +146,6 @@ export default function useSideNavLinks({
});
}
if (hasAccessToPrompts) {
links.push({
title: 'com_ui_prompts',
label: '',
icon: NotebookPen,
id: 'prompts',
Component: PromptsAccordion,
});
}
if (hasAccessToMemories && hasAccessToReadMemories) {
links.push({
title: 'com_ui_memories',
label: '',
icon: Brain,
id: 'memories',
Component: MemoryPanel,
});
}
// Scheduled chats are EXPERIMENTAL and default-OFF: the server enables them only
// when an admin opts in explicitly, so ABSENT config means disabled here too.
// Mirrors getLimits exactly — absent/null/`false` are all off, `true` is on, and the
@ -186,6 +166,26 @@ export default function useSideNavLinks({
});
}
if (hasAccessToPrompts) {
links.push({
title: 'com_ui_prompts',
label: '',
icon: NotebookPen,
id: 'prompts',
Component: PromptsAccordion,
});
}
if (hasAccessToMemories && hasAccessToReadMemories) {
links.push({
title: 'com_ui_memories',
label: '',
icon: Brain,
id: 'memories',
Component: MemoryPanel,
});
}
if (hasAccessToBookmarks) {
links.push({
title: 'com_sidepanel_conversation_tags',

View file

@ -1869,11 +1869,14 @@
"com_ui_schedule_last_run_failed": "Last run failed",
"com_ui_schedule_meridiem": "AM or PM",
"com_ui_schedule_minute": "Minute",
"com_ui_schedule_minutes_past_hour": "Minutes past the hour",
"com_ui_schedule_name_placeholder": "Morning news briefing",
"com_ui_schedule_needs_approval": "Needs approval",
"com_ui_schedule_new": "New schedule",
"com_ui_schedule_next_run": "Next run {{time}}",
"com_ui_schedule_options": "Schedule options",
"com_ui_schedule_pm": "PM",
"com_ui_schedule_prompt_placeholder": "Summarize the top AI news from the last 24 hours and highlight anything worth acting on.",
"com_ui_schedule_run_now": "Run now",
"com_ui_schedule_run_now_started": "Run started",
"com_ui_schedule_run_skipped": "Skipped",

View file

@ -225,7 +225,7 @@ test.describe('scheduled chat execution', () => {
const editDialog = page.getByRole('dialog');
const renamed = `${name} edited`;
await editDialog.locator('#schedule-name').fill(renamed);
await editDialog.getByRole('group', { name: 'Frequency' }).getByText('Daily').click();
await editDialog.getByRole('radio', { name: 'Daily' }).click();
await editDialog.getByRole('button', { name: 'Save' }).click();
await page.reload();