mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(scheduled-tasks): render prompt chips in task details and align weekday picker (#5159)
This commit is contained in:
+46
-28
@@ -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.
|
||||
@@ -56,6 +64,7 @@ export function PromptEditor({
|
||||
editor,
|
||||
placeholder,
|
||||
autoFocus = false,
|
||||
readOnly = false,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
onSubmit,
|
||||
@@ -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(() => {
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+10
-1
@@ -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
|
||||
@@ -142,6 +150,7 @@ export type PromptEditorInstance = ReturnType<typeof usePromptEditor>
|
||||
export function usePromptEditor({
|
||||
workspaceId,
|
||||
initialValue = '',
|
||||
initialContexts,
|
||||
onContextAdd,
|
||||
onPasteFiles,
|
||||
}: UsePromptEditorProps) {
|
||||
@@ -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
|
||||
|
||||
|
||||
+56
-18
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { format } from 'date-fns'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Calendar,
|
||||
ChipModal,
|
||||
@@ -8,7 +9,13 @@ import {
|
||||
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,
|
||||
@@ -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 (
|
||||
@@ -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 }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+16
-7
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,10 @@ export { Badge } from './badge/badge'
|
||||
export { Banner } from './banner/banner'
|
||||
export { Button, buttonVariants } from './button/button'
|
||||
export { ButtonGroup, ButtonGroupItem } from './button-group/button-group'
|
||||
export {
|
||||
CalendarDayCell,
|
||||
type CalendarDayCellProps,
|
||||
} from './calendar/calendar-day-cell'
|
||||
export { Checkbox } from './checkbox/checkbox'
|
||||
export { Chip, ChipLink, chipVariants } from './chip/chip'
|
||||
export { ChipChevronDown } from './chip/chip-chevron'
|
||||
|
||||
Reference in New Issue
Block a user