mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-25 03:32:12 +00:00
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:
parent
ecddfa0376
commit
d6c922f52a
8 changed files with 579 additions and 349 deletions
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
203
client/src/components/Variables/Editor.tsx
Normal file
203
client/src/components/Variables/Editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
client/src/components/Variables/index.ts
Normal file
1
client/src/components/Variables/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as VariableEditor } from './Editor';
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue