Skip to content

Commit 1c8ac05

Browse files
improvement(scheduled-tasks): move recurrence into modal body as a section (#5054)
* 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 <walif6@gmail.com>
1 parent 940506a commit 1c8ac05

7 files changed

Lines changed: 211 additions & 162 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,25 @@ export interface PlusMenuHandle {
4747
/**
4848
* Box and typography shared by the textarea and its mirror overlay — both must
4949
* produce identical line wrapping so the overlay text sits exactly over the
50-
* (transparent) textarea text. The scale is the canonical chip text-field
51-
* scale ({@link ChipTextarea}: `text-sm`, default tracking), so the editor
52-
* reads identically in the chat input and inside chip modals — one size,
53-
* everywhere.
50+
* (transparent) textarea text. The scale is the chat input's native prompt
51+
* scale (`text-[15px]`, `-0.015em` tracking); the task modal's body inherits it
52+
* so the editor reads the same whether it's the chat input or inside the modal.
5453
*/
5554
const FIELD_MIRROR_CLASSES = cn(
56-
'm-0 box-border min-h-[20px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
57-
'px-1 py-1 font-body text-sm leading-[20px]'
55+
'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
56+
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]'
5857
)
5958

6059
/**
6160
* The textarea grows to its full content height (`h-auto`, no internal scroll);
6261
* the shared scroller clips and scrolls it. Its text is transparent so the
63-
* mirror overlay shows through; only the caret paints. The placeholder uses
64-
* the canonical `--text-muted`, matching every other chip text field.
62+
* mirror overlay shows through; only the caret paints.
6563
*/
6664
export const TEXTAREA_BASE_CLASSES = cn(
6765
FIELD_MIRROR_CLASSES,
6866
'block h-auto resize-none overflow-hidden',
6967
'text-transparent caret-[var(--text-primary)] outline-none',
70-
'placeholder:text-[var(--text-muted)]',
68+
'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
7169
'focus-visible:ring-0 focus-visible:ring-offset-0'
7270
)
7371

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-control.tsx

Lines changed: 0 additions & 131 deletions
This file was deleted.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
'use client'
2+
3+
import { useRef } from 'react'
4+
import { format } from 'date-fns'
5+
import { ChipDatePicker, ChipModalField, ChipModalSeparator, Switch } from '@/components/emcn'
6+
import type {
7+
Recurrence,
8+
RecurrenceFrequency,
9+
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
10+
11+
const WEEKDAY_PRESET = [1, 2, 3, 4, 5]
12+
/** Seed count when the user first chooses "ends after N runs". */
13+
const DEFAULT_END_AFTER_COUNT = 10
14+
/** Cadence a task falls back to when the user first flips on recurrence. */
15+
const DEFAULT_RECURRING_FREQUENCY = 'daily'
16+
17+
/** The frequency presets the dropdown authors, keyed by a synthetic option value. */
18+
type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'custom'
19+
20+
function isWeekdayPreset(weekdays: number[]): boolean {
21+
return (
22+
weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d))
23+
)
24+
}
25+
26+
/** Collapses a recurring recurrence into the single dropdown value that represents it. */
27+
function frequencyOptionFor(recurrence: Recurrence): FrequencyOption {
28+
if (recurrence.frequency === 'weekly')
29+
return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly'
30+
// Exhaustiveness fallback: callers gate on `isRecurring`, so `once` never
31+
// reaches here at runtime, but the dropdown can't represent it — mapping it to
32+
// a recurring default keeps the return type `FrequencyOption` without a cast.
33+
if (recurrence.frequency === 'once') return DEFAULT_RECURRING_FREQUENCY
34+
return recurrence.frequency
35+
}
36+
37+
interface RecurrenceSectionProps {
38+
recurrence: Recurrence
39+
onChange: (recurrence: Recurrence) => void
40+
/** The launch day, so weekly/monthly labels name the weekday and day-of-month. */
41+
launchDate: string
42+
}
43+
44+
/**
45+
* The repeat + end controls for a scheduled task, rendered as a body section
46+
* below the prompt: a "Recurring" {@link Switch} that toggles a one-time launch
47+
* into a repeat, and — once on — the frequency preset and how it ends (never, on
48+
* a date, or after N runs).
49+
*
50+
* Composed as a sibling between the prompt body and footer; it owns its own
51+
* leading separator and mirrors {@link ChipModalBody}'s spacing
52+
* (`gap-4 px-2 pt-4 pb-4.5`) so every {@link ChipModalField} lands at the same
53+
* effective `px-4` as the modal header/footer — no changes to the `ChipModal`
54+
* primitives.
55+
*/
56+
export function RecurrenceSection({ recurrence, onChange, launchDate }: RecurrenceSectionProps) {
57+
/**
58+
* The cadence to reinstate when recurrence is toggled back on. Toggling off
59+
* collapses `frequency` to `once`, dropping which preset was active, so the
60+
* last recurring cadence is cached here and restored — a paused "Weekly on
61+
* Mon" returns as weekly, not silently reset to daily. Written during render
62+
* (an idempotent cache), so it is current before the toggle handler reads it.
63+
*/
64+
const lastRecurringFrequency = useRef<RecurrenceFrequency>(DEFAULT_RECURRING_FREQUENCY)
65+
if (recurrence.frequency !== 'once') lastRecurringFrequency.current = recurrence.frequency
66+
67+
const launch = new Date(`${launchDate}T00:00`)
68+
const isRecurring = recurrence.frequency !== 'once'
69+
70+
const frequencyOptions = [
71+
{ value: 'daily', label: 'Daily' },
72+
{ value: 'weekly', label: `Weekly on ${format(launch, 'EEE')}` },
73+
{ value: 'weekdays', label: 'Weekdays' },
74+
{ value: 'monthly', label: `Monthly on the ${format(launch, 'do')}` },
75+
...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []),
76+
]
77+
78+
/**
79+
* Flips the one-time launch into a repeat and back. Toggling off keeps the
80+
* recurrence shape (weekdays, end, and a passed-through `custom` cron) on the
81+
* object and only collapses `frequency` to `once`; toggling back on reinstates
82+
* the remembered cadence, so neither a weekly preset nor a conversationally
83+
* authored custom cron is silently rewritten to daily.
84+
*/
85+
const handleRecurringToggle = (checked: boolean) => {
86+
onChange({ ...recurrence, frequency: checked ? lastRecurringFrequency.current : 'once' })
87+
}
88+
89+
const handleFrequencyChange = (value: string) => {
90+
const option = value as FrequencyOption
91+
switch (option) {
92+
case 'daily':
93+
onChange({ ...recurrence, frequency: 'daily', weekdays: [], cron: undefined })
94+
return
95+
case 'weekly':
96+
onChange({
97+
...recurrence,
98+
frequency: 'weekly',
99+
weekdays: [launch.getDay()],
100+
cron: undefined,
101+
})
102+
return
103+
case 'weekdays':
104+
onChange({
105+
...recurrence,
106+
frequency: 'weekly',
107+
weekdays: [...WEEKDAY_PRESET],
108+
cron: undefined,
109+
})
110+
return
111+
case 'monthly':
112+
onChange({ ...recurrence, frequency: 'monthly', weekdays: [], cron: undefined })
113+
return
114+
case 'custom':
115+
onChange({ ...recurrence, frequency: 'custom' })
116+
}
117+
}
118+
119+
const handleEndChange = (value: string) => {
120+
if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } })
121+
else if (value === 'on')
122+
onChange({ ...recurrence, end: { type: 'on', date: format(launch, 'yyyy-MM-dd') } })
123+
else {
124+
const count = recurrence.end.type === 'after' ? recurrence.end.count : DEFAULT_END_AFTER_COUNT
125+
onChange({ ...recurrence, end: { type: 'after', count } })
126+
}
127+
}
128+
129+
return (
130+
<div className='flex flex-col'>
131+
<ChipModalSeparator />
132+
<div className='flex flex-col gap-4 px-2 pt-4 pb-4.5'>
133+
<ChipModalField type='custom' title='Recurring'>
134+
<Switch checked={isRecurring} onCheckedChange={handleRecurringToggle} />
135+
</ChipModalField>
136+
137+
{isRecurring && (
138+
<>
139+
<ChipModalField
140+
type='dropdown'
141+
title='Frequency'
142+
value={frequencyOptionFor(recurrence)}
143+
options={frequencyOptions}
144+
onChange={handleFrequencyChange}
145+
/>
146+
147+
<ChipModalField
148+
type='dropdown'
149+
title='Ends'
150+
value={recurrence.end.type}
151+
options={[
152+
{ value: 'never', label: 'No end' },
153+
{ value: 'on', label: 'Ends on' },
154+
{ value: 'after', label: 'Ends after' },
155+
]}
156+
onChange={handleEndChange}
157+
/>
158+
159+
{recurrence.end.type === 'on' && (
160+
<ChipModalField type='custom' title='End date'>
161+
<ChipDatePicker
162+
value={recurrence.end.date}
163+
onChange={(date) => onChange({ ...recurrence, end: { type: 'on', date } })}
164+
fullWidth
165+
/>
166+
</ChipModalField>
167+
)}
168+
169+
{recurrence.end.type === 'after' && (
170+
<ChipModalField
171+
type='input'
172+
title='Number of runs'
173+
value={String(recurrence.end.count)}
174+
onChange={(value) => {
175+
const count = Math.max(1, Math.floor(Number(value) || 1))
176+
onChange({ ...recurrence, end: { type: 'after', count } })
177+
}}
178+
/>
179+
)}
180+
</>
181+
)}
182+
</div>
183+
</div>
184+
)
185+
}

0 commit comments

Comments
 (0)