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>
This commit is contained in:
Emir Karabeg
2026-06-15 10:25:40 -07:00
committed by GitHub
co-authored by waleed
parent 940506ad09
commit 1c8ac05808
7 changed files with 211 additions and 162 deletions
@@ -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'
)
@@ -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 (
<div className='flex flex-wrap items-center gap-2'>
<ChipDropdown
leftIcon={RefreshCw}
value={frequencyOptionFor(recurrence)}
options={frequencyOptions}
onChange={handleFrequencyChange}
matchTriggerWidth={false}
flush
/>
{recurrence.frequency !== 'once' && (
<ChipDropdown
value={recurrence.end.type}
options={[
{ value: 'never', label: 'No end' },
{ value: 'on', label: 'Ends on' },
{ value: 'after', label: 'Ends after' },
]}
onChange={handleEndChange}
matchTriggerWidth={false}
flush
/>
)}
{recurrence.frequency !== 'once' && recurrence.end.type === 'on' && (
<ChipDatePicker
value={recurrence.end.date}
onChange={(date) => onChange({ ...recurrence, end: { type: 'on', date } })}
flush
/>
)}
{recurrence.frequency !== 'once' && recurrence.end.type === 'after' && (
<ChipInput
className='w-[120px]'
inputMode='numeric'
value={String(recurrence.end.count)}
onChange={(event) => {
const count = Math.max(1, Math.floor(Number(event.target.value) || 1))
onChange({ ...recurrence, end: { type: 'after', count } })
}}
endAdornment={<span className='text-[var(--text-muted)] text-caption'>runs</span>}
/>
)}
</div>
)
}
@@ -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<RecurrenceFrequency>(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 (
<div className='flex flex-col'>
<ChipModalSeparator />
<div className='flex flex-col gap-4 px-2 pt-4 pb-4.5'>
<ChipModalField type='custom' title='Recurring'>
<Switch checked={isRecurring} onCheckedChange={handleRecurringToggle} />
</ChipModalField>
{isRecurring && (
<>
<ChipModalField
type='dropdown'
title='Frequency'
value={frequencyOptionFor(recurrence)}
options={frequencyOptions}
onChange={handleFrequencyChange}
/>
<ChipModalField
type='dropdown'
title='Ends'
value={recurrence.end.type}
options={[
{ value: 'never', label: 'No end' },
{ value: 'on', label: 'Ends on' },
{ value: 'after', label: 'Ends after' },
]}
onChange={handleEndChange}
/>
{recurrence.end.type === 'on' && (
<ChipModalField type='custom' title='End date'>
<ChipDatePicker
value={recurrence.end.date}
onChange={(date) => onChange({ ...recurrence, end: { type: 'on', date } })}
fullWidth
/>
</ChipModalField>
)}
{recurrence.end.type === 'after' && (
<ChipModalField
type='input'
title='Number of runs'
value={String(recurrence.end.count)}
onChange={(value) => {
const count = Math.max(1, Math.floor(Number(value) || 1))
onChange({ ...recurrence, end: { type: 'after', count } })
}}
/>
)}
</>
)}
</div>
</div>
)
}
@@ -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: (
<RecurrenceControl
recurrence={recurrence}
onChange={setRecurrence}
launchDate={launchDate}
/>
),
},
{ custom: <ChipDatePicker value={launchDate} onChange={editLaunchDate} flush /> },
{ custom: <ChipTimePicker value={launchTime} onChange={editLaunchTime} flush /> },
]
@@ -338,6 +330,7 @@ function TaskModalContent({
onSubmit={handleSubmit}
/>
</ChipModalPromptBody>
<RecurrenceSection recurrence={recurrence} onChange={setRecurrence} launchDate={launchDate} />
<ChipModalFooter
onCancel={close}
cancelDisabled={submitting}
@@ -1,7 +1,6 @@
'use client'
import { Plus } from '@/components/emcn'
import { Calendar } from '@/components/emcn/icons'
import { Calendar, Plus } from '@/components/emcn'
import {
type ChromeActionSpec,
ResourceChromeFallback,
@@ -62,8 +62,12 @@ import { Loader } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
/** Shared inset separator used by the header and footer edges. */
function ChipModalSeparator({ className }: { className?: string }) {
/**
* The modal's hairline divider — used by the header and footer edges, and
* exported so body sections (e.g. a settings band below a prompt) can draw the
* same line instead of re-deriving the `h-px bg-[var(--border)]` string.
*/
export function ChipModalSeparator({ className }: { className?: string }) {
return <div className={cn('h-px bg-[var(--border)]', className)} />
}
@@ -53,6 +53,7 @@ export {
ChipModalPromptBody,
type ChipModalPromptBodyProps,
type ChipModalProps,
ChipModalSeparator,
type ChipModalTab,
ChipModalTabs,
type ChipModalTabsProps,