From 1c8ac058081d0b88e52675de9e3965767ffcff63 Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:25:40 -0700 Subject: [PATCH] improvement(scheduled-tasks): move recurrence into modal body as a section (#5054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(scheduled-tasks): move recurrence into modal body as a section Replace the footer RecurrenceControl (a row of chip dropdowns) with a RecurrenceSection rendered between the prompt body and footer: a "Recurring" Switch toggles one-time vs repeat, and — once on — frequency and end (never, on a date, after N runs) are labeled ChipModalField rows aligned to the modal header/footer gutter. Toggling Recurring off now preserves the recurrence shape (cadence, end, and a passed-through custom cron) and only sets frequency: 'once', so toggling back on restores a conversationally-authored custom schedule instead of silently rewriting it to daily. Also restore the prompt editor's native scale (text-[15px], -0.015em tracking) so the editor reads the same in the chat input and the task modal body. * fix(scheduled-tasks): clear custom cron when switching frequency away from custom The Recurring toggle restores `frequency: 'custom'` when `recurrence.cron` is truthy, but switching the frequency dropdown away from custom kept the stale cron on the object — so editing a custom-cron task to Daily, then toggling Recurring off and back on, snapped it back to Custom and persisted the old cron. Clear `cron` in the non-custom frequency branches so it is present only while the cadence is genuinely custom (matching the type's "custom only" invariant), making the toggle's restore signal accurate. Also document the unreachable `once` branch in frequencyOptionFor as a type-exhaustiveness fallback (keeps the return type without a cast). * fix(scheduled-tasks): restore the prior cadence when re-enabling recurrence Toggling Recurring off collapsed frequency to 'once' but toggling back on forced 'daily' and cleared weekdays, so pausing a weekly/weekdays/monthly task and re-enabling it silently reset it to daily. Cache the last recurring cadence in a ref (written during render) and reinstate it on toggle-on, so a paused "Weekly on Mon" returns as weekly. This also subsumes the custom-cron restore — the ref remembers 'custom' across the one-time interval — so the toggle no longer special-cases cron. * improvement(scheduled-tasks): compose canonical modal separator, tidy imports Replace the recurrence section's hand-rolled `h-px bg-[var(--border)]` divider with the canonical ChipModalSeparator (now exported from the chip-modal barrel) so the modal's hairline has a single source of truth. Also unify loading.tsx icon imports onto the @/components/emcn barrel. --------- Co-authored-by: waleed --- .../user-input/components/constants.ts | 16 +- .../task-modal/recurrence-control.tsx | 131 ------------- .../task-modal/recurrence-section.tsx | 185 ++++++++++++++++++ .../components/task-modal/task-modal.tsx | 29 ++- .../[workspaceId]/scheduled-tasks/loading.tsx | 3 +- .../emcn/components/chip-modal/chip-modal.tsx | 8 +- apps/sim/components/emcn/components/index.ts | 1 + 7 files changed, 211 insertions(+), 162 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-control.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx 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,