chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,60 @@
'use client'
import { chipContentGap, chipPrimaryFillTokens, cn } from '@sim/emcn'
import { format } from 'date-fns'
import type {
CalendarEvent,
ScheduledTask,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface CalendarEventChipProps {
event: CalendarEvent
onSelect: (task: ScheduledTask) => void
/** Right-click — open the task's context menu at the cursor. */
onContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
/** Layout/sizing only (`w-full`, `min-w-0 flex-1`); chrome lives here. */
className?: string
}
/**
* Compact task pill rendered inside a month day cell or a time-grid slot — the
* one leaf shared by both grids. Every task renders identically regardless of
* status — plaintext start time + title, no icons or status colors; the
* details modal carries the state. The pill is the grid's real `<button>` (its
* parent cells are plain clickable `<div>`s), so tasks are the tab-reachable
* elements; clicks stop propagating so the cell underneath doesn't also open
* the create modal. A paused task (`task.disabled`) renders dimmed — the one
* status the pill signals visually, since a paused task can sit on the calendar
* indefinitely without running.
*/
export function CalendarEventChip({
event,
onSelect,
onContextMenu,
className,
}: CalendarEventChipProps) {
return (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onSelect(event.task)
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
onContextMenu(event.task, e)
}}
className={cn(
'flex min-w-0 cursor-pointer items-center truncate rounded-md px-1.5 py-0.5 text-left text-caption outline-none transition-colors',
chipContentGap,
chipPrimaryFillTokens,
'hover-hover:bg-[var(--text-body)] dark:hover-hover:bg-[var(--text-secondary)]',
event.task.disabled && 'opacity-45',
className
)}
>
<span className='flex-shrink-0'>{format(event.start, 'h:mm a')}</span>
<span className='min-w-0 truncate'>{event.title}</span>
</button>
)
}
@@ -0,0 +1 @@
export { CalendarEventChip } from './calendar-event-chip'
@@ -0,0 +1,84 @@
'use client'
import {
Check,
Chip,
ChipDatePicker,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@sim/emcn'
import { format, parseISO } from 'date-fns'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import type { CalendarScope } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
const SCOPE_OPTIONS: { value: CalendarScope; label: string }[] = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]
interface CalendarToolbarProps {
scope: CalendarScope
anchor: Date
label: string
onPrev: () => void
onNext: () => void
onToday: () => void
onSelectDate: (date: Date) => void
onScopeChange: (scope: CalendarScope) => void
}
/**
* Calendar ribbon: a "Today" jump and the period-label date picker on the left;
* the prev/next chevrons and the scope picker on the right. The controls are
* bare chips — the period label is a ghost `ChipDatePicker` that jumps the view
* to any picked date — and the scope picker is a `DropdownMenu`, matching the
* Filter/Sort menus on the resource options bar.
*/
export function CalendarToolbar({
scope,
anchor,
label,
onPrev,
onNext,
onToday,
onSelectDate,
onScopeChange,
}: CalendarToolbarProps) {
const scopeLabel = SCOPE_OPTIONS.find((option) => option.value === scope)?.label ?? 'Week'
return (
<div className='flex items-center justify-between border-[var(--border)] border-b px-4 py-2.5'>
<div className='flex items-center'>
<Chip onClick={onToday}>Today</Chip>
<ChipDatePicker
variant='ghost'
label={label}
value={format(anchor, 'yyyy-MM-dd')}
onChange={(value) => onSelectDate(parseISO(value))}
/>
</div>
<div className='flex items-center'>
<Chip leftIcon={ChevronLeft} aria-label='Previous' onClick={onPrev} />
<Chip leftIcon={ChevronRight} aria-label='Next' onClick={onNext} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Chip>{scopeLabel}</Chip>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' alignOffset={6}>
{SCOPE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onSelect={() => onScopeChange(option.value)}>
{option.label}
{option.value === scope && (
<Check className='ml-auto size-[12px] text-[var(--text-tertiary)]' />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { CalendarToolbar } from './calendar-toolbar'
@@ -0,0 +1,4 @@
export { CalendarEventChip } from './calendar-event-chip'
export { CalendarToolbar } from './calendar-toolbar'
export { MonthGrid } from './month-grid'
export { TimeGrid } from './time-grid'
@@ -0,0 +1 @@
export { MonthGrid } from './month-grid'
@@ -0,0 +1,168 @@
'use client'
import { chipPrimaryFillTokens, cn } from '@sim/emcn'
import { format } from 'date-fns'
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
import {
type CalendarDayCell,
type MonthGrid as MonthGridData,
WEEKDAY_LABELS,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
import {
type CalendarEvent,
dayKey,
type ScheduledTask,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
/**
* Lines of task content a month day cell shows before collapsing the rest
* behind a "N more" overflow line. Capping at a fixed line count keeps every
* cell's height contribution bounded no matter how many tasks pile onto a day.
*/
const MAX_DAY_EVENT_LINES = 3
interface MonthGridProps {
grid: MonthGridData
onSelectDay: (date: Date) => void
onSelectTask: (task: ScheduledTask) => void
/** A task pill was right-clicked — open its context menu at the cursor. */
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
/** Drill into the day scope, where overflowing tasks have room to render. */
onShowDay: (date: Date) => void
eventsByDay?: Map<string, CalendarEvent[]>
}
/**
* One day in the month grid. Clicking empty space opens the create modal; the
* cell is a plain clickable `<div>` so the task pills inside can be real
* `<button>`s without nesting interactive elements. Tasks stack vertically up
* to {@link MAX_DAY_EVENT_LINES} lines — beyond that the last line becomes a
* plaintext "N more" that jumps to the day view.
*
* The day number carries a 1px optical inset (`ml-px`) on top of the shared
* `p-1.5` cell padding: digit glyphs and the cap-height weekday labels above
* carry different side bearings, so box-flush alignment reads visibly off at
* this size. 1px is the tuned value — 0 reads short of the label, 2px reads
* past it.
*/
function DayCell({
cell,
events,
colIndex,
onSelect,
onSelectTask,
onTaskContextMenu,
onShowDay,
}: {
cell: CalendarDayCell
events: CalendarEvent[]
colIndex: number
onSelect: (date: Date) => void
onSelectTask: (task: ScheduledTask) => void
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
onShowDay: (date: Date) => void
}) {
const visible =
events.length > MAX_DAY_EVENT_LINES ? events.slice(0, MAX_DAY_EVENT_LINES - 1) : events
const hiddenCount = events.length - visible.length
return (
<div
onClick={() => onSelect(cell.date)}
className={cn(
'flex min-h-0 min-w-0 cursor-pointer flex-col items-start gap-1 overflow-hidden border-[var(--border)] border-r border-b p-1.5 transition-colors hover-hover:bg-[var(--surface-active)]',
colIndex === 0 && 'pl-6',
colIndex === 6 && 'pr-6'
)}
>
<span
className={cn(
'ml-px flex h-[26px] flex-shrink-0 items-center rounded-lg text-caption',
cell.isToday
? cn('w-[26px] justify-center', chipPrimaryFillTokens)
: cell.isCurrentMonth
? 'text-[var(--text-body)]'
: 'text-[var(--text-muted)]'
)}
>
{format(cell.date, 'd')}
</span>
<div className='flex w-full min-w-0 flex-col gap-0.5'>
{visible.map((event) => (
<CalendarEventChip
key={event.id}
event={event}
onSelect={onSelectTask}
onContextMenu={onTaskContextMenu}
/>
))}
{hiddenCount > 0 && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onShowDay(cell.date)
}}
className='cursor-pointer px-1.5 py-0.5 text-left text-[var(--text-muted)] text-micro outline-none transition-colors hover-hover:text-[var(--text-body)]'
>
{hiddenCount} more
</button>
)}
</div>
</div>
)
}
/**
* Month scope: a sticky weekday header over a 7-column grid of day cells that
* fills the body height. All seven tracks are equal, so the border-to-border
* column rhythm is even; the edge cells span clear to the panel edges (the page
* gutter stays hoverable and clickable) and inset their own content via
* `pl-6`/`pr-6`. Events flow in via `eventsByDay` — the single injection point
* the container fills.
*/
export function MonthGrid({
grid,
onSelectDay,
onSelectTask,
onTaskContextMenu,
onShowDay,
eventsByDay,
}: MonthGridProps) {
return (
<div className='flex h-full min-h-0 flex-col'>
<div className='sticky top-0 z-10 grid grid-cols-7 border-[var(--border)] border-b bg-[var(--bg)]'>
{WEEKDAY_LABELS.map((label, index) => (
<div
key={label}
className={cn(
'p-1.5 text-[var(--text-muted)] text-caption',
index === 0 && 'pl-6',
index === WEEKDAY_LABELS.length - 1 && 'pr-6'
)}
>
{label}
</div>
))}
</div>
<div
className='grid min-h-0 flex-1 grid-cols-7'
style={{ gridTemplateRows: `repeat(${grid.weeks.length}, minmax(0, 1fr))` }}
>
{grid.weeks.map((week) =>
week.map((cell, colIndex) => (
<DayCell
key={cell.date.toISOString()}
cell={cell}
colIndex={colIndex}
events={eventsByDay?.get(dayKey(cell.date)) ?? []}
onSelect={onSelectDay}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
onShowDay={onShowDay}
/>
))
)}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { TimeGrid } from './time-grid'
@@ -0,0 +1,235 @@
'use client'
import { useEffect, useState } from 'react'
import { chipPrimaryFillTokens, cn } from '@sim/emcn'
import { format } from 'date-fns'
import { zonedClockDate } from '@/lib/core/utils/timezone'
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
import {
type CalendarDayCell,
EVENT_CHIP_HEIGHT,
formatHourLabel,
formatSlotTime,
layoutColumn,
TIME_SLOT_HEIGHT,
timeToOffset,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
import {
type CalendarEvent,
dayKey,
type ScheduledTask,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
const GUTTER_WIDTH = 56
/** Re-render cadence for the current-time indicator. */
const TICK_MS = 60_000
interface TimeGridProps {
/** One column per day: 7 for week scope, 1 for day scope. */
days: CalendarDayCell[]
hours: number[]
/** The viewer's effective timezone — positions the now-line. */
timezone: string
onSelectSlot: (date: Date, time: string) => void
onSelectTask: (task: ScheduledTask) => void
/** A task pill was right-clicked — open its context menu at the cursor. */
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
eventsByDay?: Map<string, CalendarEvent[]>
}
/**
* Live now-line drawn over today's column — a chip-primary dot at the left edge
* and a hairline across the column, positioned by {@link timeToOffset}. Renders
* nothing until mounted (keeps SSR output stable, avoiding a hydration mismatch
* on the time-dependent offset), then ticks once a minute so the line advances.
* Positioned in `timezone` so it tracks the same zone the day columns render in.
* The parent column is `relative`; this is `absolute`.
*/
function CurrentTimeIndicator({ timezone }: { timezone: string }) {
const [now, setNow] = useState<Date | null>(null)
useEffect(() => {
setNow(new Date())
const interval = setInterval(() => setNow(new Date()), TICK_MS)
return () => clearInterval(interval)
}, [])
if (!now) return null
return (
<div
style={{ top: timeToOffset(zonedClockDate(now, timezone)) }}
className='pointer-events-none absolute inset-x-0 z-20'
>
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-0 left-0 size-[10px] rounded-full bg-[var(--text-primary)] dark:bg-white' />
<div className='-translate-y-1/2 absolute inset-x-0 top-0 h-[2px] bg-[var(--text-primary)] dark:bg-white' />
</div>
)
}
/**
* One hour cell in a day column: a click target that opens the create modal
* seeded to this hour, plus the hour's gridlines. Tasks are not rendered here —
* they live in the day's {@link DayEvents} overlay so each sits at its exact
* minute rather than snapping to the top of the hour.
*/
function HourCell({
date,
hour,
isLastColumn,
onSelect,
}: {
date: Date
hour: number
isLastColumn: boolean
onSelect: (date: Date, time: string) => void
}) {
return (
<div
onClick={() => onSelect(date, formatSlotTime(hour))}
style={{ height: TIME_SLOT_HEIGHT }}
className={cn(
'cursor-pointer border-[var(--border)] border-r border-b transition-colors hover-hover:bg-[var(--surface-active)]',
isLastColumn && 'pr-6'
)}
/>
)
}
/**
* A day column's task pills, each absolutely positioned at its exact start time
* via {@link timeToOffset}. The layer is non-interactive so empty space falls
* through to the hour cells beneath (click-to-create); the pills re-enable
* pointer events. The layer clips to the day's bounds so a late-night pill never
* spills past the final hour row. Coincident tasks overlap by design.
*/
function DayEvents({
events,
isLastColumn,
onSelectTask,
onTaskContextMenu,
}: {
events: CalendarEvent[]
isLastColumn: boolean
onSelectTask: (task: ScheduledTask) => void
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
}) {
const placed = layoutColumn(events, EVENT_CHIP_HEIGHT)
return (
<div
className={cn(
'pointer-events-none absolute inset-y-0 left-0.5 z-10 overflow-hidden',
isLastColumn ? 'right-6' : 'right-0.5'
)}
>
{placed.map(({ item: event, topPx, lane, lanes }) => (
<div
key={event.id}
style={{
top: topPx,
height: EVENT_CHIP_HEIGHT,
left: `${(lane / lanes) * 100}%`,
width: `${(1 / lanes) * 100}%`,
}}
className='pointer-events-auto absolute pr-0.5'
>
<CalendarEventChip
event={event}
onSelect={onSelectTask}
onContextMenu={onTaskContextMenu}
className='h-full w-full'
/>
</div>
))}
</div>
)
}
/**
* Shared time-based grid for the week (7 columns) and day (1 column) scopes: a
* sticky day header, a fixed hour gutter, and a stack of hour slots per day.
* Column widths come from a CSS grid template shared by the header and body so
* they stay aligned. The sticky header paints chrome on the day cells only —
* its gutter spacer is transparent and border-free, so the hour labels scroll
* clear to the top of the viewport. Today's column is `relative` and hosts the
* {@link CurrentTimeIndicator}. Events flow in via `eventsByDay` — the single
* injection point the container fills.
*/
export function TimeGrid({
days,
hours,
timezone,
onSelectSlot,
onSelectTask,
onTaskContextMenu,
eventsByDay,
}: TimeGridProps) {
const columnsStyle = {
gridTemplateColumns: `${GUTTER_WIDTH}px repeat(${days.length}, minmax(0, 1fr))`,
}
return (
<div className='flex h-full min-h-0 flex-col'>
<div data-time-grid-header style={columnsStyle} className='sticky top-0 z-20 grid'>
<div className='border-[var(--border)] border-r' />
{days.map((day, dayIndex) => (
<div
key={day.date.toISOString()}
className={cn(
'flex items-center justify-center gap-1.5 border-[var(--border)] border-r border-b bg-[var(--bg)] py-2',
dayIndex === days.length - 1 && 'pr-6'
)}
>
<span className='text-[var(--text-muted)] text-caption'>{format(day.date, 'EEE')}</span>
<span
className={cn(
'flex size-[26px] items-center justify-center rounded-lg text-caption',
day.isToday ? chipPrimaryFillTokens : 'text-[var(--text-body)]'
)}
>
{format(day.date, 'd')}
</span>
</div>
))}
</div>
<div style={columnsStyle} className='grid'>
<div className='flex flex-col'>
{hours.map((hour) => (
<div
key={hour}
style={{ height: TIME_SLOT_HEIGHT }}
className='relative border-[var(--border)] border-r'
>
<span className='-translate-y-1/2 absolute top-0 right-1.5 text-[var(--text-muted)] text-micro'>
{formatHourLabel(hour)}
</span>
</div>
))}
</div>
{days.map((day, dayIndex) => (
<div key={day.date.toISOString()} className='relative flex flex-col'>
{day.isToday && <CurrentTimeIndicator timezone={timezone} />}
{hours.map((hour) => (
<HourCell
key={hour}
date={day.date}
hour={hour}
isLastColumn={dayIndex === days.length - 1}
onSelect={onSelectSlot}
/>
))}
<DayEvents
events={eventsByDay?.get(dayKey(day.date)) ?? []}
isLastColumn={dayIndex === days.length - 1}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
/>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { ScheduleCalendar } from './schedule-calendar'
@@ -0,0 +1,141 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { zonedClockDate } from '@/lib/core/utils/timezone'
import {
CalendarToolbar,
MonthGrid,
TimeGrid,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components'
import {
buildCalendarGrid,
type CalendarScope,
formatScopeLabel,
timeToOffset,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
import type {
CalendarEvent,
ScheduledTask,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface ScheduleCalendarProps {
scope: CalendarScope
anchor: Date
today: Date
/** The viewer's effective timezone — positions the now-line and centering. */
timezone: string
onScopeChange: (scope: CalendarScope) => void
onPrev: () => void
onNext: () => void
onToday: () => void
onSelectDate: (date: Date) => void
onSelectSlot: (date: Date, time?: string) => void
/** A task pill was clicked — open its details modal. */
onSelectTask: (task: ScheduledTask) => void
/** A task pill was right-clicked — open its context menu at the cursor. */
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
/** A month cell's overflow line was clicked — jump to that day's view. */
onShowDay: (date: Date) => void
/** Day-bucketed events feeding both the month grid and the time grid. */
eventsByDay?: Map<string, CalendarEvent[]>
}
/**
* Calendar body for the scheduled-tasks page. Owns the scroll region and view
* dispatch: it renders the toolbar, derives the grid from the page's
* `useCalendar` state, and switches between the month grid and the shared time
* grid on the grid discriminant.
*
* Scroll behavior: entering week/day scope, and "Today" presses (signaled via an
* internal `scrollSignal`), center the current time in the viewport; month scope
* resets to the top. Plain prev/next navigation never re-centers. Today presses
* scroll smoothly as an orientation cue; mount and scope switches position
* instantly (animating initial placement would read as a glitch). Centering is
* computed from the time-grid header height plus {@link timeToOffset} rather than
* the now-line element, so it works even on first paint before the line mounts.
*
* Event injection is the single integration point — `eventsByDay` is threaded
* straight into both grids, which forward it to their cells.
*/
export function ScheduleCalendar({
scope,
anchor,
today,
timezone,
onScopeChange,
onPrev,
onNext,
onToday,
onSelectDate,
onSelectSlot,
onSelectTask,
onTaskContextMenu,
onShowDay,
eventsByDay,
}: ScheduleCalendarProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const lastScrollSignalRef = useRef(0)
const [scrollSignal, setScrollSignal] = useState(0)
const grid = useMemo(() => buildCalendarGrid(scope, anchor, today), [scope, anchor, today])
const label = useMemo(() => formatScopeLabel(scope, anchor), [scope, anchor])
const handleToday = useCallback(() => {
onToday()
setScrollSignal((signal) => signal + 1)
}, [onToday])
useEffect(() => {
const region = scrollRef.current
if (!region) return
const behavior: ScrollBehavior =
scrollSignal !== lastScrollSignalRef.current ? 'smooth' : 'auto'
lastScrollSignalRef.current = scrollSignal
if (scope === 'month') {
region.scrollTo({ top: 0, behavior })
return
}
const header = region.querySelector('[data-time-grid-header]')
const headerHeight = header ? header.getBoundingClientRect().height : 0
const target =
headerHeight + timeToOffset(zonedClockDate(new Date(), timezone)) - region.clientHeight / 2
region.scrollTo({ top: Math.max(0, target), behavior })
}, [scope, scrollSignal, timezone])
return (
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<CalendarToolbar
scope={scope}
anchor={anchor}
label={label}
onPrev={onPrev}
onNext={onNext}
onToday={handleToday}
onSelectDate={onSelectDate}
onScopeChange={onScopeChange}
/>
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto overscroll-none'>
{grid.kind === 'month' ? (
<MonthGrid
grid={grid}
onSelectDay={(date) => onSelectSlot(date)}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
onShowDay={onShowDay}
eventsByDay={eventsByDay}
/>
) : (
<TimeGrid
days={grid.kind === 'week' ? grid.days : [grid.day]}
hours={grid.hours}
timezone={timezone}
onSelectSlot={(date, time) => onSelectSlot(date, time)}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
eventsByDay={eventsByDay}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { ScheduleListContextMenu } from './schedule-list-context-menu'
@@ -0,0 +1,53 @@
'use client'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
interface ScheduleListContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onCreateSchedule?: () => void
disableCreate?: boolean
}
export function ScheduleListContextMenu({
isOpen,
position,
onClose,
onCreateSchedule,
disableCreate = false,
}: ScheduleListContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
>
{onCreateSchedule && (
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateSchedule}>
<Plus />
New scheduled task
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { TaskContextMenu } from './task-context-menu'
@@ -0,0 +1,110 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Duplicate as DuplicateIcon, Pause, Pencil, Play, Trash } from '@sim/emcn/icons'
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface TaskContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
/** The right-clicked task; its status decides which actions render. */
task: ScheduledTask | null
onEdit: () => void
/** Opens a new-task modal pre-filled from this task. */
onDuplicate: () => void
/** Pauses an active recurring task — suspends its future runs. */
onPause: () => void
/** Resumes a paused recurring task. */
onResume: () => void
onDelete: () => void
}
/**
* Right-click menu for a calendar task pill. Upcoming (`pending`) tasks can be
* edited or deleted, and recurring ones paused or resumed; any task can be
* duplicated into a new one. Finished tasks open their read-only record on
* click, so the menu only offers Duplicate.
*/
export function TaskContextMenu({
isOpen,
position,
onClose,
task,
onEdit,
onDuplicate,
onPause,
onResume,
onDelete,
}: TaskContextMenuProps) {
const isUpcoming = task?.status === 'pending'
/** Pause/Resume applies to recurring tasks only — one-time tasks carry no cadence. */
const canPauseResume = isUpcoming && task?.recurring === true
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
>
{isUpcoming ? (
<>
<DropdownMenuItem onSelect={onEdit}>
<Pencil />
Edit
</DropdownMenuItem>
{canPauseResume &&
(task?.disabled ? (
<DropdownMenuItem onSelect={onResume}>
<Play />
Resume
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={onPause}>
<Pause />
Pause
</DropdownMenuItem>
))}
<DropdownMenuItem onSelect={onDuplicate}>
<DuplicateIcon />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={onDuplicate}>
<DuplicateIcon />
Duplicate
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { TaskDeleteDialog } from './task-delete-dialog'
@@ -0,0 +1,98 @@
'use client'
import {
ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { Calendar } from '@sim/emcn/icons'
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface TaskDeleteDialogProps {
/** The task targeted for deletion, or `null` to keep the dialog closed. */
task: ScheduledTask | null
onClose: () => void
/** Delete just the targeted occurrence of a recurring task. */
onDeleteOccurrence: (task: ScheduledTask) => void
/** Delete a one-time task, or the entire recurring series. */
onDeleteSeries: (task: ScheduledTask) => void
}
/**
* Deletion confirmation for a scheduled task. A one-time task takes a single
* confirm; a recurring task offers the calendar-app choice between deleting
* this occurrence and deleting the whole series.
*/
export function TaskDeleteDialog({
task,
onClose,
onDeleteOccurrence,
onDeleteSeries,
}: TaskDeleteDialogProps) {
if (task && !task.recurring) {
return (
<ChipConfirmModal
open
onOpenChange={(open) => {
if (!open) onClose()
}}
title='Delete scheduled task'
text='This task will be removed from the calendar and will not run.'
confirm={{
label: 'Delete',
onClick: () => {
onDeleteSeries(task)
onClose()
},
}}
/>
)
}
return (
<ChipModal
open={task !== null}
onOpenChange={(open) => {
if (!open) onClose()
}}
size='sm'
srTitle='Delete recurring task'
>
{task && (
<>
<ChipModalHeader icon={Calendar} onClose={onClose}>
Delete recurring task
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-body)] text-sm'>
This is a recurring task. Delete only this occurrence, or the entire series?
</p>
</ChipModalBody>
<ChipModalFooter
onCancel={onClose}
secondaryActions={[
{
label: 'This task',
variant: 'destructive',
onClick: () => {
onDeleteOccurrence(task)
onClose()
},
},
]}
primaryAction={{
label: 'All tasks',
variant: 'destructive',
onClick: () => {
onDeleteSeries(task)
onClose()
},
}}
/>
</>
)}
</ChipModal>
)
}
@@ -0,0 +1 @@
export { TaskDetailsModal } from './task-details-modal'
@@ -0,0 +1,106 @@
'use client'
import {
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
chipFieldSurfaceClass,
cn,
} from '@sim/emcn'
import { Calendar } from '@sim/emcn/icons'
import { format } from 'date-fns'
import { useParams } from 'next/navigation'
import {
PromptEditor,
usePromptEditor,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import type {
ScheduledTask,
ScheduledTaskStatus,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
/**
* Plaintext copy per task state: the status label and the verb that titles the
* run-time field — the tense carries the state ("Ran" is done, "Failed" errored).
* No icons, no status colors, by design. Total over the status union for type
* safety, though `pending` tasks open the edit `TaskModal` instead.
*/
const STATUS_COPY: Record<ScheduledTaskStatus, { label: string; timeTitle: string }> = {
pending: { label: 'Pending', timeTitle: 'Runs' },
error: { label: 'Error', timeTitle: 'Failed' },
completed: { label: 'Completed', timeTitle: 'Ran' },
}
interface TaskDetailsModalProps {
/** The running or finished task to show. `null` keeps the modal closed. */
task: ScheduledTask | null
onClose: () => void
}
/**
* Read-only record modal for tasks that are running or already finished —
* pending tasks open the edit `TaskModal` instead. Three plaintext fields:
* Status and the run time as copy fields, the prompt as a view-only chip editor.
*/
export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) {
return (
<ChipModal
open={task !== null}
onOpenChange={(open) => {
if (!open) onClose()
}}
size='md'
srTitle='Scheduled task'
>
{/* Key by the occurrence id so switching tasks while the modal stays open
remounts the content — the editor seeds prompt + contexts on mount, so
without a fresh mount it would keep showing the first task's prompt. */}
{task && <TaskDetailsContent key={task.id} task={task} onClose={onClose} />}
</ChipModal>
)
}
/**
* Inner content, mounted only while a task is shown (the Radix portal unmounts
* closed content). Holding the read-only editor here keeps its mention-data
* queries from firing on page load and re-seeds from the task on each open.
*/
function TaskDetailsContent({ task, onClose }: { task: ScheduledTask; onClose: () => void }) {
const { workspaceId } = useParams<{ workspaceId: string }>()
/**
* Seed the stored resource mentions (files, tables, knowledge) as the editor's
* initial contexts — these can't be recovered from the prompt text alone. The
* mount chipify pass then merges integration `@`-mentions and `/`-skills on top
* (they DO chipify from text), so the overlay renders the full set. Seeding is
* deliberate over a post-mount `setContexts`, which would clobber the
* auto-registered integration/skill contexts.
*/
const editor = usePromptEditor({
workspaceId,
initialValue: task.prompt,
initialContexts: task.contexts,
})
return (
<>
<ChipModalHeader icon={Calendar} onClose={onClose}>
Scheduled task
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='copy' title='Status' value={STATUS_COPY[task.status].label} />
<ChipModalField
type='copy'
title={STATUS_COPY[task.status].timeTitle}
value={format(task.runAt, "EEEE, MMMM d, yyyy 'at' h:mm a")}
/>
<ChipModalField type='custom' title='Prompt'>
<div className={cn(chipFieldSurfaceClass, 'max-h-[200px] overflow-y-auto px-1 py-0.5')}>
<PromptEditor editor={editor} readOnly aria-label='Prompt' />
</div>
</ChipModalField>
</ChipModalBody>
<ChipModalFooter onCancel={onClose} primaryAction={{ label: 'Done', onClick: onClose }} />
</>
)
}
@@ -0,0 +1 @@
export { type TaskDraft, type TaskEditSeed, TaskModal, type TaskPrefill } from './task-modal'
@@ -0,0 +1,300 @@
'use client'
import { useRef } from 'react'
import {
CalendarDayCell,
ChipDatePicker,
ChipModalField,
ChipModalSeparator,
Switch,
} from '@sim/emcn'
import { format } from 'date-fns'
import type {
MonthlyMode,
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'
/** Sunday-first weekday order with single-letter labels and full names for a11y. */
const WEEKDAYS = [
{ value: 0, short: 'S', name: 'Sunday' },
{ value: 1, short: 'M', name: 'Monday' },
{ value: 2, short: 'T', name: 'Tuesday' },
{ value: 3, short: 'W', name: 'Wednesday' },
{ value: 4, short: 'T', name: 'Thursday' },
{ value: 5, short: 'F', name: 'Friday' },
{ value: 6, short: 'S', name: 'Saturday' },
] as const
/** Ordinal words for the 1st5th weekday-of-month, matching a calendar app's labels. */
const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth'] as const
/** The frequency presets the dropdown authors, keyed by a synthetic option value. */
type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'yearly' | '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. `once` maps to the default cadence as an exhaustiveness
* fallback: callers gate on `isRecurring`, so it never reaches here at runtime,
* but the dropdown can't represent it — mapping it keeps the return type
* `FrequencyOption` without a cast.
*/
function frequencyOptionFor(recurrence: Recurrence): FrequencyOption {
if (recurrence.frequency === 'weekly')
return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly'
if (recurrence.frequency === 'monthly') return 'monthly'
if (recurrence.frequency === 'yearly') return 'yearly'
if (recurrence.frequency === 'custom') return 'custom'
if (recurrence.frequency === 'once') return DEFAULT_RECURRING_FREQUENCY
return recurrence.frequency
}
/**
* The monthly sub-options, derived from the launch date the same way a calendar
* app offers them: repeat on the day number, on the ordinal weekday of the
* month (e.g. the third Tuesday), or on the last weekday of the month.
*
* The ordinal anchor is offered only for the 1st4th occurrence: a 5th
* occurrence is always the month's last weekday, so — like a calendar app — it
* is folded into the "last weekday" option rather than offering a "fifth" that
* would silently skip months without a 5th occurrence.
*/
function monthlyModeOptions(launch: Date): Array<{ value: MonthlyMode; label: string }> {
const weekdayName = format(launch, 'EEEE')
const ordinal = Math.ceil(launch.getDate() / 7)
const options: Array<{ value: MonthlyMode; label: string }> = [
{ value: 'day-of-month', label: `On day ${format(launch, 'd')}` },
]
if (ordinal <= 4)
options.push({ value: 'nth-weekday', label: `On the ${ORDINALS[ordinal - 1]} ${weekdayName}` })
options.push({ value: 'last-weekday', label: `On the last ${weekdayName}` })
return options
}
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, its cadence detail (the
* weekly day toggles or the monthly anchor), 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 selectedWeekdays = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launch.getDay()]
const monthlyOptions = monthlyModeOptions(launch)
const monthlyMode = recurrence.monthlyMode ?? 'day-of-month'
// If the launch date drifted to a 5th occurrence, the nth anchor is no longer
// offered; fall back to "last weekday", which is exactly what it compiles to.
const monthlyValue = monthlyOptions.some((option) => option.value === monthlyMode)
? monthlyMode
: 'last-weekday'
const frequencyOptions = [
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'weekdays', label: 'Weekdays' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: `Yearly on ${format(launch, 'MMM d')}` },
...(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: [],
monthlyMode: recurrence.monthlyMode ?? 'day-of-month',
cron: undefined,
})
return
case 'yearly':
onChange({ ...recurrence, frequency: 'yearly', weekdays: [], cron: undefined })
return
case 'custom':
onChange({ ...recurrence, frequency: 'custom' })
}
}
/** Toggles a weekday on or off, never letting the last selected day be cleared. */
const handleWeekdayToggle = (day: number) => {
const isSelected = selectedWeekdays.includes(day)
if (isSelected && selectedWeekdays.length === 1) return
const weekdays = isSelected
? selectedWeekdays.filter((d) => d !== day)
: [...selectedWeekdays, day].sort((a, b) => a - b)
onChange({ ...recurrence, weekdays })
}
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}
/>
{recurrence.frequency === 'weekly' && (
<ChipModalField type='custom' title='Repeat on'>
{/* A one-row extract of the calendar: seven equal day cells built
from the same {@link CalendarDayCell} the date picker uses, so
the weekday toggles read as a sibling of the calendar rather than
a separate segmented bar. */}
<div className='grid grid-cols-7 gap-1'>
{WEEKDAYS.map((weekday) => {
const selected = selectedWeekdays.includes(weekday.value)
return (
<CalendarDayCell
key={weekday.value}
selected={selected}
fullWidth
aria-pressed={selected}
aria-label={weekday.name}
onClick={() => handleWeekdayToggle(weekday.value)}
>
{weekday.short}
</CalendarDayCell>
)
})}
</div>
</ChipModalField>
)}
{recurrence.frequency === 'monthly' && (
<ChipModalField
type='dropdown'
title='On'
value={monthlyValue}
options={monthlyOptions}
onChange={(value) => onChange({ ...recurrence, monthlyMode: value as MonthlyMode })}
/>
)}
<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>
)
}
@@ -0,0 +1,347 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
ChipDatePicker,
ChipModal,
ChipModalFooter,
type ChipModalFooterSlotAction,
ChipModalHeader,
ChipModalPromptBody,
ChipTimePicker,
} from '@sim/emcn'
import { Calendar } from '@sim/emcn/icons'
import { format } from 'date-fns'
import { useParams } from 'next/navigation'
import { wallClockNow, zonedWallClockToUtc } from '@/lib/core/utils/timezone'
import {
PromptEditor,
usePromptEditor,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
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,
type Recurrence,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
import { useTimezone } from '@/hooks/queries/general-settings'
import type { ChatContext } from '@/stores/panel'
const DEFAULT_TIME = '09:00'
const PAST_LAUNCH_MESSAGE = "You can't schedule a one-time task in the past"
/** Whether a one-time launch has already passed, evaluated in the task's `timezone`. */
function isLaunchInPast(launchDate: string, launchTime: string, timezone: string): boolean {
return zonedWallClockToUtc(`${launchDate}T${launchTime}`, timezone) < new Date()
}
/**
* The next top of the hour in `timezone`, as a `{ date, time }` wall-clock pair.
* The `Z` suffix keeps the step pure wall-clock arithmetic (adding an hour and
* rolling the date at 23:xx), not a timezone conversion.
*/
function nextTopOfHour(timezone: string): { date: string; time: string } {
const next = new Date(`${wallClockNow(timezone)}:00Z`)
next.setUTCMinutes(0, 0, 0)
next.setUTCHours(next.getUTCHours() + 1)
return { date: next.toISOString().slice(0, 10), time: next.toISOString().slice(11, 16) }
}
/**
* Seeds the launch date/time for a create. A slot with a specific hour uses it;
* a whole-day slot (month cell) and the no-slot header action both default to
* the next top of the hour in `timezone` when the target day is today — so the
* modal never opens with a past, already-disabled default — and to 9am on a
* future day.
*/
function defaultLaunch(
slot: CalendarSlot | null | undefined,
timezone: string
): { date: string; time: string } {
if (slot?.time) return { date: format(slot.date, 'yyyy-MM-dd'), time: slot.time }
const upcoming = nextTopOfHour(timezone)
if (slot?.date) {
const date = format(slot.date, 'yyyy-MM-dd')
const today = wallClockNow(timezone).slice(0, 10)
return date === today ? upcoming : { date, time: DEFAULT_TIME }
}
return upcoming
}
/** The data a task create or edit captures. */
export interface TaskDraft {
prompt: string
/** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */
contexts?: ChatContext[]
launchDate: string
launchTime: string
timezone: string
recurrence: Recurrence
}
/** Pre-filled fields shared by the edit and duplicate flows. */
export interface TaskPrefill {
prompt: string
/** Stored `@`-mention contexts, re-registered so they carry over. */
contexts?: ChatContext[]
launchDate: string
launchTime: string
/** The task's own zone; the modal seeds AND submits in it so unchanged times never drift. */
timezone: string
recurrence: Recurrence
}
/** Seeds the modal when editing an existing task, recovered from its schedule. */
export interface TaskEditSeed extends TaskPrefill {
scheduleId: string
}
interface TaskModalProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Slot seeding a create — the clicked day/time, or `null` from the header action. */
slot?: CalendarSlot | null
/** Seed for an edit; when set the modal opens in edit mode (Save + Delete). */
edit?: TaskEditSeed | null
/** Pre-fill for a create (duplicate): opens in create mode with every field copied. */
prefill?: TaskPrefill | null
/**
* Receives the captured draft on submit (create and save alike). May return a
* promise — the modal awaits it, keeping itself open until the task persists
* and closing only on success, so a failed save never silently discards the draft.
*/
onSubmit: (draft: TaskDraft) => void | Promise<void>
/** Asks the parent to start the delete flow (which handles the recurring this/all choice). */
onRequestDelete?: () => void
}
/**
* 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 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,
onOpenChange,
slot,
edit,
prefill,
onSubmit,
onRequestDelete,
}: TaskModalProps) {
const [submitting, setSubmitting] = useState(false)
/**
* While a save is in flight, swallow every dismiss path — Cancel, header X,
* Escape, and overlay click all route through this one handler — so an
* in-progress create/edit can't be abandoned and lose its draft. `submitting`
* lives here (not in the unmounted-on-close content) so this guard can see it.
*
* The programmatic close on a *successful* submit is intentionally NOT blocked:
* `handleSubmit` runs in the pre-submit render where `submitting` was still
* false, so its `close()` resolves to that render's handler and passes through,
* while user dismisses fire from the current (submitting) render and are caught
* here. Keep `submitting` as render state — moving it to a ref or memoizing this
* handler with `submitting` in deps would make the success-close start blocking.
*/
const handleOpenChange = (next: boolean) => {
if (!next && submitting) return
onOpenChange(next)
}
return (
<ChipModal
open={open}
onOpenChange={handleOpenChange}
size='lg'
srTitle={edit ? 'Edit scheduled task' : 'New scheduled task'}
>
<TaskModalContent
onOpenChange={handleOpenChange}
slot={slot}
edit={edit}
prefill={prefill}
onSubmit={onSubmit}
onRequestDelete={onRequestDelete}
submitting={submitting}
setSubmitting={setSubmitting}
/>
</ChipModal>
)
}
interface TaskModalContentProps extends Omit<TaskModalProps, 'open'> {
/** Whether a save is in flight — owned by {@link TaskModal} so the dismiss guard can read it. */
submitting: boolean
setSubmitting: (submitting: boolean) => void
}
/**
* Inner content, mounted only while the dialog is open (the Radix portal
* unmounts closed content). Holding the editor here keeps its mention-data
* queries from firing on page load and re-seeds from `edit`/`slot` on each open.
*/
function TaskModalContent({
onOpenChange,
slot,
edit,
prefill,
onSubmit,
onRequestDelete,
submitting,
setSubmitting,
}: TaskModalContentProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const source = edit ?? prefill
const accountTimezone = useTimezone()
const timezone = source?.timezone ?? accountTimezone
const editor = usePromptEditor({ workspaceId, initialValue: source?.prompt })
const setContexts = editor.setContexts
/**
* Re-registers a seeded task's stored `@`-mentions once on open: the editor
* seeds from `initialValue` text only, never its contexts. Runs once per open
* since the dialog's content remounts each time it opens.
*/
useEffect(() => {
if (source?.contexts && source.contexts.length > 0) setContexts(source.contexts)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const seedFromSource =
source &&
!(
source.recurrence.frequency === 'once' &&
isLaunchInPast(source.launchDate, source.launchTime, timezone)
)
const seed = seedFromSource
? { date: source.launchDate, time: source.launchTime }
: defaultLaunch(slot, timezone)
const [launchDate, setLaunchDate] = useState(seed.date)
const [launchTime, setLaunchTime] = useState(seed.time)
const [recurrence, setRecurrence] = useState<Recurrence>(
() => source?.recurrence ?? DEFAULT_RECURRENCE
)
const launchEditedRef = useRef(false)
/**
* Synchronous mirror of `submitting` that gates {@link handleSubmit}. The
* `submitting` state only reflects after a re-render, so two invocations in the
* same tick (Enter racing the click) could both pass a state-based guard; the
* ref flips immediately, so the second is rejected before it can fire a second
* mutation.
*/
const submittingRef = useRef(false)
/**
* Re-seed a blank create's default launch when the effective zone resolves
* after mount — `useTimezone()` starts on the browser fallback, so without
* this the next-top-of-the-hour default (and its past-launch guard) would be
* computed in the wrong zone and submitted in the resolved one. No-op once the
* user edits the fields, and skipped for slot/edit/duplicate seeds, whose
* launch is zone-stable (slot times are zone-independent; source seeds carry
* the task's own fixed zone).
*/
useEffect(() => {
if (launchEditedRef.current || source || slot) return
const next = defaultLaunch(null, timezone)
setLaunchDate(next.date)
setLaunchTime(next.time)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timezone])
const editLaunchDate = (date: string) => {
launchEditedRef.current = true
setLaunchDate(date)
}
const editLaunchTime = (time: string) => {
launchEditedRef.current = true
setLaunchTime(time)
}
const close = () => onOpenChange(false)
const isOneTime = recurrence.frequency === 'once'
const isPastLaunch = isOneTime && isLaunchInPast(launchDate, launchTime, timezone)
const promptText = editor.value.trim()
/**
* Submits the draft and waits for it to persist. The synchronous
* {@link submittingRef} guard blocks a double-submit (Enter racing the click).
* The modal closes only when the save resolves; a rejection leaves it open so
* the draft survives — the mutation hook already surfaces the error via toast,
* so it is swallowed here rather than duplicated. Both the ref and the
* `submitting` state are always cleared, so the button can never stick disabled
* while the modal stays open.
*/
const handleSubmit = async () => {
if (!promptText || isPastLaunch || submittingRef.current) return
submittingRef.current = true
setSubmitting(true)
const persisted = await Promise.resolve(
onSubmit({
prompt: editor.getPlainValue().trim(),
contexts: editor.contexts.length > 0 ? editor.contexts : undefined,
launchDate,
launchTime,
timezone,
recurrence,
})
)
.then(() => true)
.catch(() => false)
submittingRef.current = false
setSubmitting(false)
if (persisted) close()
}
/**
* 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
? [
{
label: 'Delete',
variant: 'destructive' as const,
onClick: onRequestDelete,
disabled: submitting,
},
]
: []),
{ custom: <ChipDatePicker value={launchDate} onChange={editLaunchDate} flush /> },
{ custom: <ChipTimePicker value={launchTime} onChange={editLaunchTime} flush /> },
]
return (
<>
<ChipModalHeader icon={Calendar} onClose={close}>
{edit ? 'Edit scheduled task' : 'New scheduled task'}
</ChipModalHeader>
<ChipModalPromptBody>
<PromptEditor
editor={editor}
placeholder='Use @ and launch Sim to...'
autoFocus
onSubmit={handleSubmit}
/>
</ChipModalPromptBody>
<RecurrenceSection recurrence={recurrence} onChange={setRecurrence} launchDate={launchDate} />
<ChipModalFooter
onCancel={close}
cancelDisabled={submitting}
secondaryActions={secondaryActions}
primaryAction={{
label: submitting ? (edit ? 'Saving...' : 'Scheduling...') : edit ? 'Save' : 'Schedule',
onClick: handleSubmit,
disabled: !promptText || isPastLaunch || submitting,
disabledTooltip: isPastLaunch ? PAST_LAUNCH_MESSAGE : undefined,
}}
/>
</>
)
}