Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export interface PromptEditorProps extends PromptEditorKeyPolicy {
placeholder?: string
/** Focuses the editor (caret at end) on mount. */
autoFocus?: boolean
/**
* Renders the editor as a non-editable display surface: the textarea becomes
* `readOnly` (so the chip overlay still paints `@`-mention / `/`-skill chips
* and the text stays selectable/copyable) and the caret-anchored resource and
* skill menus are not mounted. Use for read-only records — e.g. a finished
* scheduled task — where the prompt should render with chips but not be edited.
*/
readOnly?: boolean
/**
* Layout/sizing only — a height cap (`max-h-[200px]`) or fill (`flex-1`)
* for the scroll container. The text chrome is owned by the editor.
Expand Down Expand Up @@ -56,6 +64,7 @@ export function PromptEditor({
editor,
placeholder,
autoFocus = false,
readOnly = false,
className,
'aria-label': ariaLabel,
onSubmit,
Expand All @@ -73,22 +82,24 @@ export function PromptEditor({
}, [value, textareaRef])

useEffect(() => {
if (autoFocus) editor.focusAtEnd()
if (autoFocus && !readOnly) editor.focusAtEnd()
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only focus
}, [])

/**
* Clicking the editor's empty regions (padding, space below the last line)
* focuses the textarea; clicks on the textarea itself keep native caret
* placement.
* placement. No-op in read-only mode: the surface is display-only, so a
* padding click should not pull focus onto the non-editable textarea.
*/
const handleSurfaceClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (readOnly) return
if (e.target === textareaRef.current) return
if ((e.target as HTMLElement).closest('button')) return
textareaRef.current?.focus()
},
[textareaRef]
[readOnly, textareaRef]
)

const overlayContent = useMemo(() => {
Expand Down Expand Up @@ -167,38 +178,45 @@ export function PromptEditor({
<textarea
ref={textareaRef}
value={value}
onChange={editor.handleInputChange}
onKeyDown={(e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty })}
onPaste={editor.handlePaste}
readOnly={readOnly}
onChange={readOnly ? undefined : editor.handleInputChange}
onKeyDown={
readOnly ? undefined : (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty })
}
onPaste={readOnly ? undefined : editor.handlePaste}
onCopy={editor.handleCopy}
onCut={editor.handleCut}
onSelect={editor.handleSelectAdjust}
onMouseUp={editor.handleSelectAdjust}
onCut={readOnly ? undefined : editor.handleCut}
onSelect={readOnly ? undefined : editor.handleSelectAdjust}
onMouseUp={readOnly ? undefined : editor.handleSelectAdjust}
placeholder={placeholder}
aria-label={ariaLabel}
rows={1}
className={TEXTAREA_BASE_CLASSES}
className={cn(TEXTAREA_BASE_CLASSES, readOnly && 'cursor-default caret-transparent')}
/>
</div>

<PlusMenuDropdown
ref={editor.plusMenuRef}
availableResources={editor.availableResources}
onResourceSelect={editor.insertResource}
onClose={editor.handlePlusMenuClose}
textareaRef={editor.textareaRef}
pendingCursorRef={editor.pendingCursorRef}
mentionQuery={editor.mentionQuery ?? undefined}
/>
<SkillsMenuDropdown
ref={editor.skillsMenuRef}
skills={editor.skills}
onSkillSelect={editor.handleSkillSelect}
onClose={editor.handleSkillsMenuClose}
textareaRef={editor.textareaRef}
pendingCursorRef={editor.pendingCursorRef}
slashQuery={editor.slashQuery ?? undefined}
/>
{!readOnly && (
<>
<PlusMenuDropdown
ref={editor.plusMenuRef}
availableResources={editor.availableResources}
onResourceSelect={editor.insertResource}
onClose={editor.handlePlusMenuClose}
textareaRef={editor.textareaRef}
pendingCursorRef={editor.pendingCursorRef}
mentionQuery={editor.mentionQuery ?? undefined}
/>
<SkillsMenuDropdown
ref={editor.skillsMenuRef}
skills={editor.skills}
onSkillSelect={editor.handleSkillSelect}
onClose={editor.handleSkillsMenuClose}
textareaRef={editor.textareaRef}
pendingCursorRef={editor.pendingCursorRef}
slashQuery={editor.slashQuery ?? undefined}
/>
</>
)}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ export interface UsePromptEditorProps {
workspaceId: string
/** Initial text. Chipified (`@`-mentions / `/`-skills converted) on mount. */
initialValue?: string
/**
* Contexts to seed the editor with — restored resource mentions (files,
* tables, knowledge) that cannot be recovered from the prompt text alone.
* Seed these rather than calling `setContexts` after mount: the mount
* chipify pass MERGES integration `@`-mentions and `/`-skills on top, so a
* post-mount `setContexts` would clobber those auto-registered contexts.
*/
initialContexts?: ChatContext[]
/**
* Notified when a context is added through an interactive path — a mention
* pick, a resource drop, or a skill pick. Paste re-registration is
Expand Down Expand Up @@ -142,6 +150,7 @@ export type PromptEditorInstance = ReturnType<typeof usePromptEditor>
export function usePromptEditor({
workspaceId,
initialValue = '',
initialContexts,
onContextAdd,
onPasteFiles,
}: UsePromptEditorProps) {
Expand Down Expand Up @@ -170,7 +179,7 @@ export function usePromptEditor({
const slashRangeRef = useRef<{ start: number; end: number } | null>(null)
const [slashQuery, setSlashQuery] = useState<string | null>(null)

const contextManagement = useContextManagement({ message: value })
const contextManagement = useContextManagement({ message: value, initialContexts })
const contextManagementRef = useRef(contextManagement)
contextManagementRef.current = contextManagement

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
'use client'

import { format } from 'date-fns'
import { useParams } from 'next/navigation'
import {
Calendar,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
chipFieldSurfaceClass,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import {
PromptEditor,
usePromptEditor,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import type {
ScheduledTask,
ScheduledTaskStatus,
Expand All @@ -35,7 +42,7 @@ interface TaskDetailsModalProps {
/**
* 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 textarea.
* Status and the run time as copy fields, the prompt as a view-only chip editor.
*/
export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) {
return (
Expand All @@ -47,23 +54,54 @@ export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) {
size='md'
srTitle='Scheduled task'
>
{task && (
<>
<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='textarea' title='Prompt' value={task.prompt} viewOnly />
</ChipModalBody>
<ChipModalFooter onCancel={onClose} primaryAction={{ label: 'Done', onClick: onClose }} />
</>
)}
{/* 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 }} />
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

import { useRef } from 'react'
import { format } from 'date-fns'
import { Chip, ChipDatePicker, ChipModalField, ChipModalSeparator, Switch } from '@/components/emcn'
import {
CalendarDayCell,
ChipDatePicker,
ChipModalField,
ChipModalSeparator,
Switch,
} from '@/components/emcn'
import type {
MonthlyMode,
Recurrence,
Expand Down Expand Up @@ -219,21 +225,24 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren

{recurrence.frequency === 'weekly' && (
<ChipModalField type='custom' title='Repeat on'>
<div className='flex gap-1'>
{/* 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 (
<Chip
<CalendarDayCell
key={weekday.value}
active={selected}
flush
className='min-w-0 flex-1 justify-center'
selected={selected}
fullWidth
aria-pressed={selected}
aria-label={weekday.name}
onClick={() => handleWeekdayToggle(weekday.value)}
>
{weekday.short}
</Chip>
</CalendarDayCell>
)
})}
</div>
Expand Down
61 changes: 61 additions & 0 deletions apps/sim/components/emcn/components/calendar/calendar-day-cell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client'

import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react'
import { chipVariants } from '@/components/emcn/components/chip/chip'
import { cn } from '@/lib/core/utils/cn'

export interface CalendarDayCellProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
/** Strong `primary` fill — the selected calendar day or an active weekday toggle. */
selected?: boolean
/** The `border` shadow-ring marking today. Ignored while `selected`. */
today?: boolean
/**
* Fills the container width (the weekday-toggle row) instead of the fixed
* 30px square used by the calendar's month grid.
*/
fullWidth?: boolean
children: ReactNode
}

/**
* The single day pill shared by the {@link Calendar} month grid and any
* chip-aligned day toggle (e.g. the scheduled-task weekly "Repeat on" row).
* Built from `chipVariants` so the chrome — height, radius, centered glyph,
* `primary` selected fill, `border` today ring — lives in one place and the
* row of weekday toggles reads as a sibling of the date picker rather than a
* separate control.
*
* @example
* <CalendarDayCell selected={isSelected} today={isToday} onClick={pick}>{day}</CalendarDayCell>
*
* @example
* // Weekday toggle: fill the column, drive selection with `aria-pressed`.
* <CalendarDayCell selected={on} fullWidth aria-pressed={on} aria-label='Monday' onClick={toggle}>M</CalendarDayCell>
*/
export const CalendarDayCell = forwardRef<HTMLButtonElement, CalendarDayCellProps>(
function CalendarDayCell(
{ selected = false, today = false, fullWidth = false, className, children, type, ...props },
ref
) {
return (
<button
ref={ref}
type={type ?? 'button'}
className={cn(
chipVariants({
variant: selected ? 'primary' : today ? 'border' : undefined,
flush: true,
}),
'justify-center p-0',
fullWidth ? 'h-[30px] w-full' : 'size-[30px]',
!selected && 'text-[var(--text-body)]',
className
)}
{...props}
>
{children}
</button>
)
}
)
16 changes: 3 additions & 13 deletions apps/sim/components/emcn/components/calendar/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { CalendarDayCell } from '@/components/emcn/components/calendar/calendar-day-cell'
import { Chip, chipVariants } from '@/components/emcn/components/chip/chip'
import { chipContentLabelClass } from '@/components/emcn/components/chip/chip-chrome'
import { cn } from '@/lib/core/utils/cn'
Expand Down Expand Up @@ -172,20 +173,9 @@ export function Calendar({ value, onChange, className }: CalendarProps) {

return (
<div key={day} className='flex h-[34px] items-center justify-center'>
<button
type='button'
onClick={() => selectDay(day)}
className={cn(
chipVariants({
variant: isSelected ? 'primary' : isToday ? 'border' : undefined,
flush: true,
}),
'size-[30px] justify-center p-0',
!isSelected && 'text-[var(--text-body)]'
)}
>
<CalendarDayCell selected={isSelected} today={isToday} onClick={() => selectDay(day)}>
{day}
</button>
</CalendarDayCell>
</div>
)
})}
Expand Down
Loading
Loading