feat: redesign scheduled task dialog to match Claude Code original

Refactor PermissionModeSelector and ModelSelector to support controlled
(prop-driven) mode alongside the existing store-driven mode, enabling
reuse in the new task creation dialog. Rewrite NewTaskModal with embedded
prompt editor that combines textarea + permission/model/folder controls,
matching Claude's original design. Add desktop-online warning banner on
the scheduled tasks list page and default folder to current session workDir.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 21:56:23 +08:00
parent f81fe41797
commit 602c61ad4c
5 changed files with 223 additions and 112 deletions

View File

@ -15,11 +15,21 @@ const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [
{ value: 'max', label: 'Max' },
]
export function ModelSelector() {
const { currentModel, availableModels, effortLevel, setModel, setEffort } = useSettingsStore()
type Props = {
/** Controlled mode: model ID override */
value?: string
/** Controlled mode: called on change instead of updating global store */
onChange?: (modelId: string) => void
}
export function ModelSelector({ value, onChange }: Props = {}) {
const { currentModel: storeModel, availableModels, effortLevel, setModel, setEffort } = useSettingsStore()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const isControlled = value !== undefined
const selectedModel = isControlled ? availableModels.find((m) => m.id === value) || null : storeModel
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
@ -51,7 +61,7 @@ export function ModelSelector() {
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">auto_awesome</span>
<span>{currentModel?.name ?? 'Select model'}</span>
<span>{selectedModel?.name ?? 'Select model'}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
</button>
@ -64,11 +74,18 @@ export function ModelSelector() {
</div>
<div className="space-y-1">
{availableModels.map((model) => {
const isSelected = model.id === currentModel?.id
const isSelected = model.id === selectedModel?.id
return (
<button
key={model.id}
onClick={() => { setModel(model.id); setOpen(false) }}
onClick={() => {
if (isControlled) {
onChange?.(model.id)
} else {
setModel(model.id)
}
setOpen(false)
}}
className={`
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors
${isSelected
@ -104,8 +121,8 @@ export function ModelSelector() {
</div>
</div>
{/* Effort */}
<div className="border-t border-[var(--color-border)] p-3">
{/* Effort — hidden in controlled mode (not relevant for task creation) */}
{!isControlled && <div className="border-t border-[var(--color-border)] p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
Effort
</div>
@ -129,7 +146,7 @@ export function ModelSelector() {
)
})}
</div>
</div>
</div>}
</div>
)}
</div>

View File

@ -56,8 +56,16 @@ const MODE_LABELS: Record<PermissionMode, string> = {
dontAsk: 'Don\'t ask',
}
export function PermissionModeSelector({ workDir: workDirProp }: { workDir?: string } = {}) {
const { permissionMode, setPermissionMode } = useSettingsStore()
type Props = {
workDir?: string
/** Controlled mode: override current value */
value?: PermissionMode
/** Controlled mode: called on change instead of updating global store */
onChange?: (mode: PermissionMode) => void
}
export function PermissionModeSelector({ workDir: workDirProp, value, onChange }: Props = {}) {
const { permissionMode: storeMode, setPermissionMode } = useSettingsStore()
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
@ -65,6 +73,9 @@ export function PermissionModeSelector({ workDir: workDirProp }: { workDir?: str
const [confirmDialog, setConfirmDialog] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const isControlled = value !== undefined
const currentMode = isControlled ? value : storeMode
const activeSession = sessions.find((s) => s.id === activeSessionId)
const workDir = workDirProp || activeSession?.workDir || '~'
@ -90,8 +101,8 @@ export function PermissionModeSelector({ workDir: workDirProp }: { workDir?: str
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
>
<span className="material-symbols-outlined text-[14px]">{MODE_ICONS[permissionMode]}</span>
<span>{MODE_LABELS[permissionMode]}</span>
<span className="material-symbols-outlined text-[14px]">{MODE_ICONS[currentMode]}</span>
<span>{MODE_LABELS[currentMode]}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
</button>
@ -109,14 +120,18 @@ export function PermissionModeSelector({ workDir: workDirProp }: { workDir?: str
setConfirmDialog(true)
return
}
void setPermissionMode(item.value)
setSessionPermissionMode(item.value)
if (isControlled) {
onChange?.(item.value)
} else {
void setPermissionMode(item.value)
setSessionPermissionMode(item.value)
}
setOpen(false)
}}
className={`
w-full flex items-start gap-3 px-4 py-3 text-left transition-colors
hover:bg-[var(--color-surface-hover)]
${item.value === permissionMode ? 'bg-[var(--color-surface-selected)]' : ''}
${item.value === currentMode ? 'bg-[var(--color-surface-selected)]' : ''}
`}
>
<span className={`material-symbols-outlined text-[20px] mt-0.5 ${item.color || 'text-[var(--color-text-secondary)]'}`}>
@ -126,7 +141,7 @@ export function PermissionModeSelector({ workDir: workDirProp }: { workDir?: str
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.label}</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{item.description}</div>
</div>
{item.value === permissionMode && (
{item.value === currentMode && (
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)] mt-0.5" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
@ -189,8 +204,12 @@ export function PermissionModeSelector({ workDir: workDirProp }: { workDir?: str
</button>
<button
onClick={() => {
void setPermissionMode('bypassPermissions')
setSessionPermissionMode('bypassPermissions')
if (isControlled) {
onChange?.('bypassPermissions')
} else {
void setPermissionMode('bypassPermissions')
setSessionPermissionMode('bypassPermissions')
}
setConfirmDialog(false)
}}
className="px-4 py-2 text-xs font-semibold text-white bg-[var(--color-error)] hover:opacity-90 rounded-lg transition-colors"

View File

@ -1,46 +1,58 @@
import { useState } from 'react'
import { useTaskStore } from '../../stores/taskStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { Modal } from '../shared/Modal'
import { Input } from '../shared/Input'
import { Textarea } from '../shared/Textarea'
import { Button } from '../shared/Button'
import { PromptEditor } from './PromptEditor'
import type { PermissionMode } from '../../types/settings'
type Props = {
open: boolean
onClose: () => void
}
const FREQUENCY_OPTIONS = [
{ value: '0 * * * *', label: 'Hourly' },
{ value: '0 9 * * *', label: 'Daily' },
{ value: '0 9 * * 1-5', label: 'Weekdays' },
{ value: '0 9 * * 1', label: 'Weekly' },
{ value: '0 9 1 * *', label: 'Monthly' },
type FrequencyKey = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'monthly'
const FREQUENCY_OPTIONS: Array<{ value: FrequencyKey; label: string; showTime: boolean }> = [
{ value: 'hourly', label: 'Hourly', showTime: false },
{ value: 'daily', label: 'Daily', showTime: true },
{ value: 'weekdays', label: 'Weekdays', showTime: true },
{ value: 'weekly', label: 'Weekly', showTime: true },
{ value: 'monthly', label: 'Monthly', showTime: true },
]
const PERMISSION_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'ask', label: 'Ask permissions' },
{ value: 'auto-accept', label: 'Auto accept edits' },
{ value: 'plan', label: 'Plan mode' },
{ value: 'bypass', label: 'Bypass permissions' },
]
function buildCron(freq: FrequencyKey, time: string): string {
const [hours, minutes] = time.split(':').map(Number)
switch (freq) {
case 'hourly': return '0 * * * *'
case 'daily': return `${minutes} ${hours} * * *`
case 'weekdays': return `${minutes} ${hours} * * 1-5`
case 'weekly': return `${minutes} ${hours} * * 1`
case 'monthly': return `${minutes} ${hours} 1 * *`
}
}
export function NewTaskModal({ open, onClose }: Props) {
const { createTask } = useTaskStore()
const availableModels = useSettingsStore((s) => s.availableModels)
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const activeSession = sessions.find((s) => s.id === activeSessionId)
const defaultWorkDir = activeSession?.workDir || ''
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [prompt, setPrompt] = useState('')
const [cron, setCron] = useState('0 9 * * *')
const [frequency, setFrequency] = useState<FrequencyKey>('daily')
const [time, setTime] = useState('09:00')
const [model, setModel] = useState('')
const [permissionMode, setPermissionMode] = useState('')
const [folderPath, setFolderPath] = useState('')
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
const [folderPath, setFolderPath] = useState(defaultWorkDir)
const [useWorktree, setUseWorktree] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const canSubmit = name.trim() && prompt.trim()
const canSubmit = name.trim() && description.trim() && prompt.trim()
const showTime = FREQUENCY_OPTIONS.find((o) => o.value === frequency)?.showTime ?? false
const handleSubmit = async () => {
if (!canSubmit) return
@ -48,24 +60,26 @@ export function NewTaskModal({ open, onClose }: Props) {
try {
await createTask({
name: name.trim(),
description: description.trim() || undefined,
cron,
description: description.trim(),
cron: buildCron(frequency, time),
prompt: prompt.trim(),
enabled: true,
recurring: true,
model: model || undefined,
permissionMode: permissionMode || undefined,
permissionMode: permissionMode !== 'default' ? permissionMode : undefined,
folderPath: folderPath.trim() || undefined,
useWorktree: useWorktree || undefined,
})
// Reset form
setName('')
setDescription('')
setPrompt('')
setCron('0 9 * * *')
setFrequency('daily')
setTime('09:00')
setModel('')
setPermissionMode('')
setPermissionMode('default')
setFolderPath('')
setShowAdvanced(false)
setUseWorktree(false)
onClose()
} catch (err) {
console.error('Failed to create task:', err)
@ -74,8 +88,6 @@ export function NewTaskModal({ open, onClose }: Props) {
}
}
const selectClass = 'h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]'
return (
<Modal
open={open}
@ -89,8 +101,8 @@ export function NewTaskModal({ open, onClose }: Props) {
}
>
{/* Info banner */}
<div className="flex items-start gap-2 px-3 py-2.5 rounded-[var(--radius-md)] bg-[var(--color-surface-info)] mb-4">
<span className="text-[var(--color-text-secondary)] text-sm"></span>
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] mb-5">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">info</span>
<span className="text-xs text-[var(--color-text-secondary)]">
Local tasks only run while your computer is awake.
</span>
@ -107,80 +119,61 @@ export function NewTaskModal({ open, onClose }: Props) {
<Input
label="Description"
required
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Review yesterday's commits..."
placeholder="Review yesterday's commits and flag anything concerning"
/>
<Textarea
label="Prompt"
required
{/* Prompt editor with embedded controls */}
<PromptEditor
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Look at the commits from the last 24 hours. Summarize what changed, call out any risky patterns..."
onChange={setPrompt}
placeholder="Look at the commits from the last 24 hours. Summarize what changed, call out any risky patterns or missing tests, and note anything worth following up on."
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
modelId={model}
onModelChange={setModel}
folderPath={folderPath}
onFolderPathChange={setFolderPath}
useWorktree={useWorktree}
onUseWorktreeChange={setUseWorktree}
/>
{/* Frequency */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">Frequency</label>
<select value={cron} onChange={(e) => setCron(e.target.value)} className={selectClass}>
{FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<div className="relative">
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as FrequencyKey)}
className="w-full h-10 px-3 pr-8 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)] appearance-none cursor-pointer"
>
{FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
</div>
{/* Advanced toggle */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors self-start"
>
<span
className="material-symbols-outlined text-[16px] transition-transform"
style={{ transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0deg)' }}
>
chevron_right
</span>
Advanced options
</button>
{showAdvanced && (
<div className="flex flex-col gap-4 pl-2 border-l-2 border-[var(--color-border)]">
{/* Model */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">Model</label>
<select value={model} onChange={(e) => setModel(e.target.value)} className={selectClass}>
<option value="">Default (current model)</option>
{availableModels.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
<p className="text-[10px] text-[var(--color-text-tertiary)]">Override the model used for this task.</p>
</div>
{/* Permission Mode */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">Permission mode</label>
<select value={permissionMode} onChange={(e) => setPermissionMode(e.target.value)} className={selectClass}>
{PERMISSION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Folder Path */}
<Input
label="Working directory"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/path/to/project"
{/* Time picker — shown when frequency supports specific time */}
{showTime && (
<div className="flex flex-col gap-1">
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-auto h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
style={{ maxWidth: 120 }}
/>
</div>
)}
<p className="text-xs text-[var(--color-text-tertiary)]">
Scheduled tasks use a randomized delay of up to 5 minutes to avoid rate limits.
Scheduled tasks use a randomized delay of several minutes for server performance.
</p>
</div>
</Modal>

View File

@ -0,0 +1,72 @@
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
import { ModelSelector } from '../controls/ModelSelector'
import { DirectoryPicker } from '../shared/DirectoryPicker'
import type { PermissionMode } from '../../types/settings'
type Props = {
value: string
onChange: (value: string) => void
placeholder?: string
permissionMode: PermissionMode
onPermissionModeChange: (mode: PermissionMode) => void
modelId: string
onModelChange: (modelId: string) => void
folderPath: string
onFolderPathChange: (path: string) => void
useWorktree: boolean
onUseWorktreeChange: (checked: boolean) => void
}
export function PromptEditor({
value,
onChange,
placeholder,
permissionMode,
onPermissionModeChange,
modelId,
onModelChange,
folderPath,
onFolderPathChange,
useWorktree,
onUseWorktreeChange,
}: Props) {
return (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] focus-within:border-[var(--color-border-focus)] transition-colors overflow-visible">
{/* Prompt textarea */}
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={4}
className="w-full resize-y bg-transparent px-3 py-2.5 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
style={{ minHeight: 120 }}
/>
{/* Bottom toolbar */}
<div className="border-t border-[var(--color-border)]/40 px-3 py-2 flex flex-col gap-2 bg-[var(--color-surface-container-low)] rounded-b-[var(--radius-lg)]">
{/* Row 1: Permission + Model selectors */}
<div className="flex items-center justify-between">
<PermissionModeSelector value={permissionMode} onChange={onPermissionModeChange} />
<ModelSelector value={modelId} onChange={onModelChange} />
</div>
{/* Row 2: Folder picker */}
<div className="flex items-center justify-between">
<DirectoryPicker value={folderPath} onChange={onFolderPathChange} />
</div>
{/* Bypass + no folder warning */}
{permissionMode === 'bypassPermissions' && (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-[var(--color-error)]/8 text-[10px] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[12px]">warning</span>
Bypass mode grants full system access{folderPath ? ` within ${folderPath}` : ' — select a folder to limit scope'}.
</div>
)}
</div>
</div>
)
}

View File

@ -19,16 +19,24 @@ export function ScheduledTasks() {
<div className="flex-1 overflow-y-auto">
<div className="px-10 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Scheduled tasks</h1>
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
Run tasks on a schedule or whenever you need them.
Run tasks on a schedule or whenever you need them. Type <code className="px-1 py-0.5 rounded bg-[var(--color-surface-container)] text-xs font-[var(--font-mono)]">/schedule</code> in any existing session to create one.
</p>
</div>
<Button onClick={() => openModal('new-task')}>+ New task</Button>
</div>
{/* Desktop-online notice */}
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-[var(--radius-md)] bg-[var(--color-warning)]/8 border border-[var(--color-warning)]/15 mb-6">
<span className="material-symbols-outlined text-[18px] text-[var(--color-warning)]">schedule</span>
<span className="text-xs text-[var(--color-text-secondary)]">
Scheduled tasks only run while the desktop app is open. Make sure it stays running for tasks to fire on time.
</span>
</div>
{/* Content */}
{!initialized && isLoading ? (
<div className="flex items-center justify-center py-16">
@ -42,10 +50,12 @@ export function ScheduledTasks() {
</div>
{/* New Task Modal */}
<NewTaskModal
open={activeModal === 'new-task'}
onClose={closeModal}
/>
{activeModal === 'new-task' && (
<NewTaskModal
open
onClose={closeModal}
/>
)}
</div>
)
}