improvement(scheduled-tasks): render prompt chips in task details and align weekday picker (#5159)

This commit is contained in:
Waleed
2026-06-20 16:34:55 -07:00
committed by GitHub
parent 2bbf70ec20
commit 39cfae9016
7 changed files with 196 additions and 67 deletions
@@ -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>
)
}
@@ -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
@@ -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 }} />
</>
)
}
@@ -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'