diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 9fb5b05d04..a106503220 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -47,27 +47,25 @@ export interface PlusMenuHandle { /** * Box and typography shared by the textarea and its mirror overlay — both must * produce identical line wrapping so the overlay text sits exactly over the - * (transparent) textarea text. The scale is the canonical chip text-field - * scale ({@link ChipTextarea}: `text-sm`, default tracking), so the editor - * reads identically in the chat input and inside chip modals — one size, - * everywhere. + * (transparent) textarea text. The scale is the chat input's native prompt + * scale (`text-[15px]`, `-0.015em` tracking); the task modal's body inherits it + * so the editor reads the same whether it's the chat input or inside the modal. */ const FIELD_MIRROR_CLASSES = cn( - 'm-0 box-border min-h-[20px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent', - 'px-1 py-1 font-body text-sm leading-[20px]' + 'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent', + 'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]' ) /** * The textarea grows to its full content height (`h-auto`, no internal scroll); * the shared scroller clips and scrolls it. Its text is transparent so the - * mirror overlay shows through; only the caret paints. The placeholder uses - * the canonical `--text-muted`, matching every other chip text field. + * mirror overlay shows through; only the caret paints. */ export const TEXTAREA_BASE_CLASSES = cn( FIELD_MIRROR_CLASSES, 'block h-auto resize-none overflow-hidden', 'text-transparent caret-[var(--text-primary)] outline-none', - 'placeholder:text-[var(--text-muted)]', + 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]', 'focus-visible:ring-0 focus-visible:ring-offset-0' ) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-control.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-control.tsx deleted file mode 100644 index 54cc65d572..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-control.tsx +++ /dev/null @@ -1,131 +0,0 @@ -'use client' - -import { format } from 'date-fns' -import { ChipDatePicker, ChipDropdown, ChipInput, RefreshCw } from '@/components/emcn' -import type { Recurrence } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence' - -const WEEKDAY_PRESET = [1, 2, 3, 4, 5] -/** Seed count when the user first chooses "ends after N runs". */ -const DEFAULT_END_AFTER_COUNT = 10 - -/** The frequency presets the dropdown authors, keyed by a synthetic option value. */ -type FrequencyOption = 'once' | 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'custom' - -function isWeekdayPreset(weekdays: number[]): boolean { - return ( - weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d)) - ) -} - -/** Collapses a recurrence into the single dropdown value that represents it. */ -function frequencyOptionFor(recurrence: Recurrence): FrequencyOption { - if (recurrence.frequency === 'weekly') - return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly' - return recurrence.frequency -} - -interface RecurrenceControlProps { - recurrence: Recurrence - onChange: (recurrence: Recurrence) => void - /** The launch day, so weekly/monthly labels name the weekday and day-of-month. */ - launchDate: string -} - -/** - * The repeat + end controls for a scheduled task, modeled on a calendar app's - * recurrence row: a frequency preset and — when the task repeats — how it ends - * (never, on a date, or after N runs). - */ -export function RecurrenceControl({ recurrence, onChange, launchDate }: RecurrenceControlProps) { - const launch = new Date(`${launchDate}T00:00`) - - const frequencyOptions = [ - { value: 'once', label: 'Once' }, - { value: 'daily', label: 'Daily' }, - { value: 'weekly', label: `Weekly on ${format(launch, 'EEE')}` }, - { value: 'weekdays', label: 'Weekdays' }, - { value: 'monthly', label: `Monthly on the ${format(launch, 'do')}` }, - ...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []), - ] - - const handleFrequencyChange = (value: string) => { - const option = value as FrequencyOption - switch (option) { - case 'once': - onChange({ frequency: 'once', weekdays: [], end: { type: 'never' } }) - return - case 'daily': - onChange({ ...recurrence, frequency: 'daily', weekdays: [] }) - return - case 'weekly': - onChange({ ...recurrence, frequency: 'weekly', weekdays: [launch.getDay()] }) - return - case 'weekdays': - onChange({ ...recurrence, frequency: 'weekly', weekdays: [...WEEKDAY_PRESET] }) - return - case 'monthly': - onChange({ ...recurrence, frequency: 'monthly', weekdays: [] }) - return - case 'custom': - onChange({ ...recurrence, frequency: 'custom' }) - } - } - - const handleEndChange = (value: string) => { - if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } }) - else if (value === 'on') - onChange({ ...recurrence, end: { type: 'on', date: format(launch, 'yyyy-MM-dd') } }) - else { - const count = recurrence.end.type === 'after' ? recurrence.end.count : DEFAULT_END_AFTER_COUNT - onChange({ ...recurrence, end: { type: 'after', count } }) - } - } - - return ( -
- - - {recurrence.frequency !== 'once' && ( - - )} - - {recurrence.frequency !== 'once' && recurrence.end.type === 'on' && ( - onChange({ ...recurrence, end: { type: 'on', date } })} - flush - /> - )} - - {recurrence.frequency !== 'once' && recurrence.end.type === 'after' && ( - { - const count = Math.max(1, Math.floor(Number(event.target.value) || 1)) - onChange({ ...recurrence, end: { type: 'after', count } }) - }} - endAdornment={runs} - /> - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx new file mode 100644 index 0000000000..d74ca387fb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx @@ -0,0 +1,185 @@ +'use client' + +import { useRef } from 'react' +import { format } from 'date-fns' +import { ChipDatePicker, ChipModalField, ChipModalSeparator, Switch } from '@/components/emcn' +import type { + Recurrence, + RecurrenceFrequency, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence' + +const WEEKDAY_PRESET = [1, 2, 3, 4, 5] +/** Seed count when the user first chooses "ends after N runs". */ +const DEFAULT_END_AFTER_COUNT = 10 +/** Cadence a task falls back to when the user first flips on recurrence. */ +const DEFAULT_RECURRING_FREQUENCY = 'daily' + +/** The frequency presets the dropdown authors, keyed by a synthetic option value. */ +type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'custom' + +function isWeekdayPreset(weekdays: number[]): boolean { + return ( + weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d)) + ) +} + +/** Collapses a recurring recurrence into the single dropdown value that represents it. */ +function frequencyOptionFor(recurrence: Recurrence): FrequencyOption { + if (recurrence.frequency === 'weekly') + return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly' + // Exhaustiveness fallback: callers gate on `isRecurring`, so `once` never + // reaches here at runtime, but the dropdown can't represent it — mapping it to + // a recurring default keeps the return type `FrequencyOption` without a cast. + if (recurrence.frequency === 'once') return DEFAULT_RECURRING_FREQUENCY + return recurrence.frequency +} + +interface RecurrenceSectionProps { + recurrence: Recurrence + onChange: (recurrence: Recurrence) => void + /** The launch day, so weekly/monthly labels name the weekday and day-of-month. */ + launchDate: string +} + +/** + * The repeat + end controls for a scheduled task, rendered as a body section + * below the prompt: a "Recurring" {@link Switch} that toggles a one-time launch + * into a repeat, and — once on — the frequency preset and how it ends (never, on + * a date, or after N runs). + * + * Composed as a sibling between the prompt body and footer; it owns its own + * leading separator and mirrors {@link ChipModalBody}'s spacing + * (`gap-4 px-2 pt-4 pb-4.5`) so every {@link ChipModalField} lands at the same + * effective `px-4` as the modal header/footer — no changes to the `ChipModal` + * primitives. + */ +export function RecurrenceSection({ recurrence, onChange, launchDate }: RecurrenceSectionProps) { + /** + * The cadence to reinstate when recurrence is toggled back on. Toggling off + * collapses `frequency` to `once`, dropping which preset was active, so the + * last recurring cadence is cached here and restored — a paused "Weekly on + * Mon" returns as weekly, not silently reset to daily. Written during render + * (an idempotent cache), so it is current before the toggle handler reads it. + */ + const lastRecurringFrequency = useRef(DEFAULT_RECURRING_FREQUENCY) + if (recurrence.frequency !== 'once') lastRecurringFrequency.current = recurrence.frequency + + const launch = new Date(`${launchDate}T00:00`) + const isRecurring = recurrence.frequency !== 'once' + + const frequencyOptions = [ + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: `Weekly on ${format(launch, 'EEE')}` }, + { value: 'weekdays', label: 'Weekdays' }, + { value: 'monthly', label: `Monthly on the ${format(launch, 'do')}` }, + ...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []), + ] + + /** + * Flips the one-time launch into a repeat and back. Toggling off keeps the + * recurrence shape (weekdays, end, and a passed-through `custom` cron) on the + * object and only collapses `frequency` to `once`; toggling back on reinstates + * the remembered cadence, so neither a weekly preset nor a conversationally + * authored custom cron is silently rewritten to daily. + */ + const handleRecurringToggle = (checked: boolean) => { + onChange({ ...recurrence, frequency: checked ? lastRecurringFrequency.current : 'once' }) + } + + const handleFrequencyChange = (value: string) => { + const option = value as FrequencyOption + switch (option) { + case 'daily': + onChange({ ...recurrence, frequency: 'daily', weekdays: [], cron: undefined }) + return + case 'weekly': + onChange({ + ...recurrence, + frequency: 'weekly', + weekdays: [launch.getDay()], + cron: undefined, + }) + return + case 'weekdays': + onChange({ + ...recurrence, + frequency: 'weekly', + weekdays: [...WEEKDAY_PRESET], + cron: undefined, + }) + return + case 'monthly': + onChange({ ...recurrence, frequency: 'monthly', weekdays: [], cron: undefined }) + return + case 'custom': + onChange({ ...recurrence, frequency: 'custom' }) + } + } + + const handleEndChange = (value: string) => { + if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } }) + else if (value === 'on') + onChange({ ...recurrence, end: { type: 'on', date: format(launch, 'yyyy-MM-dd') } }) + else { + const count = recurrence.end.type === 'after' ? recurrence.end.count : DEFAULT_END_AFTER_COUNT + onChange({ ...recurrence, end: { type: 'after', count } }) + } + } + + return ( +
+ +
+ + + + + {isRecurring && ( + <> + + + + + {recurrence.end.type === 'on' && ( + + onChange({ ...recurrence, end: { type: 'on', date } })} + fullWidth + /> + + )} + + {recurrence.end.type === 'after' && ( + { + const count = Math.max(1, Math.floor(Number(value) || 1)) + onChange({ ...recurrence, end: { type: 'after', count } }) + }} + /> + )} + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx index 829423504f..0d05b31fdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx @@ -18,7 +18,7 @@ import { PromptEditor, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components' -import { RecurrenceControl } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-control' +import { RecurrenceSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section' import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar' import { DEFAULT_RECURRENCE, @@ -117,10 +117,10 @@ interface TaskModalProps { /** * The "schedule a task" modal, shared by create (blank, or pre-filled from a - * duplicate) and edit (seeded from a task's schedule). The body is one prompt - * surface — the chat input's editor, so `@` mentions resources and `/` invokes - * skills exactly like talking to Sim — and the footer carries the recurrence, - * launch date/time, and (edit only) Delete. + * duplicate) and edit (seeded from a task's schedule). The body is the chat + * input's editor — so `@` mentions resources and `/` invokes skills exactly like + * talking to Sim — followed by the recurrence section; the footer carries the + * launch date/time and (edit only) Delete. */ export function TaskModal({ open, @@ -296,10 +296,11 @@ function TaskModalContent({ } /** - * Footer secondary actions. Delete is disabled while `submitting` because it - * bypasses the dismiss guard — it closes the modal via `closeTask`, not the - * guarded `onOpenChange` — so without the lock an in-flight edit and a delete - * could run against the same task at once. + * Footer secondary actions — the launch date/time pickers and (edit only) + * Delete. Delete is disabled while `submitting` because it bypasses the + * dismiss guard — it closes the modal via `closeTask`, not the guarded + * `onOpenChange` — so without the lock an in-flight edit and a delete could + * run against the same task at once. Recurrence lives in the body, not here. */ const secondaryActions: ChipModalFooterSlotAction[] = [ ...(edit && onRequestDelete @@ -312,15 +313,6 @@ function TaskModalContent({ }, ] : []), - { - custom: ( - - ), - }, { custom: }, { custom: }, ] @@ -338,6 +330,7 @@ function TaskModalContent({ onSubmit={handleSubmit} /> + } diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index 96f730c564..ae022fe63b 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -53,6 +53,7 @@ export { ChipModalPromptBody, type ChipModalPromptBodyProps, type ChipModalProps, + ChipModalSeparator, type ChipModalTab, ChipModalTabs, type ChipModalTabsProps,