diff --git a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx index 7385a4467f..f6fe813356 100644 --- a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx @@ -15,6 +15,10 @@ if (!Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; } +if (!document.execCommand) { + document.execCommand = () => true; +} + interface MockTask { id: string; name: string | null; @@ -144,6 +148,14 @@ function findFrequencySelect(): HTMLSelectElement | undefined { ); } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + afterEach(() => { act(() => root?.unmount()); container?.remove(); @@ -244,6 +256,24 @@ describe('ScheduledTasksDialog editing', () => { ); }); + it('does not render filesystem paths as skill tags', async () => { + await mount([ + baseTask({ prompt: 'check /var/log and then /agent-reproduce-align' }), + ]); + + click(document.querySelector('[aria-label="Edit"]')); + + const tags = Array.from( + document.querySelectorAll('[data-prompt-tag-serialized]'), + ); + expect(tags.map((tag) => tag.dataset.promptTagSerialized)).toEqual([ + '/agent-reproduce-align', + ]); + expect(document.querySelector('[role="textbox"]')?.textContent).toContain( + '/var/log', + ); + }); + it('inserts extension, skill, and MCP references into the created prompt', async () => { actions.createScheduledTask.mockResolvedValue(baseTask({})); @@ -316,6 +346,125 @@ describe('ScheduledTasksDialog editing', () => { ); }); + it('escapes skill names when inserting references', async () => { + actions.createScheduledTask.mockResolvedValue(baseTask({})); + await mount([]); + actions.loadSkillsStatus.mockResolvedValue({ + skills: [ + { + kind: 'skill', + name: 'review task', + description: 'Review code', + level: 'project', + modelInvocable: true, + }, + ], + }); + + click(findButton('New scheduled task')); + click(findButtonContaining('Skills')); + await flush(); + click(findButtonContaining('review task')); + await flush(); + + click(findButton('Create')); + await flush(); + + expect(actions.createScheduledTask).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '/review\\ task', + }), + ); + }); + + it('keeps the latest reference picker results when loads resolve out of order', async () => { + await mount([]); + const extensions = deferred(); + const skills = deferred(); + actions.loadExtensionsStatus.mockReturnValueOnce(extensions.promise); + actions.loadSkillsStatus.mockReturnValueOnce(skills.promise); + + click(findButton('New scheduled task')); + click(findButtonContaining('Extensions')); + click(findButtonContaining('Skills')); + + skills.resolve({ + skills: [ + { + kind: 'skill', + name: 'review', + description: 'Review code', + level: 'project', + modelInvocable: true, + }, + ], + }); + await flush(); + expect(findButtonContaining('review')).toBeTruthy(); + + extensions.resolve({ + extensions: [ + { + id: 'ext-1', + name: 'alibabacloud-compute-suite', + displayName: 'Alibaba Cloud', + description: '', + version: '1.0.0', + isActive: true, + path: '/ext', + capabilities: {}, + }, + ], + }); + await flush(); + expect(findButtonContaining('review')).toBeTruthy(); + expect(findButtonContaining('alibabacloud-compute-suite')).toBeUndefined(); + }); + + it('drops plain text only into the prompt editor', async () => { + await mount([]); + click(findButton('New scheduled task')); + const prompt = document.querySelector('[role="textbox"]'); + if (!prompt) throw new Error('prompt editor not found'); + const execCommand = vi.spyOn(document, 'execCommand').mockReturnValue(true); + const drop = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(drop, 'dataTransfer', { + value: { + getData: (type: string) => + type === 'text/plain' ? 'plain text' : 'html', + }, + }); + + act(() => { + prompt.dispatchEvent(drop); + }); + + expect(execCommand).toHaveBeenCalledWith('insertText', false, 'plain text'); + execCommand.mockRestore(); + }); + + it('caps contenteditable input at the scheduled-task prompt limit', async () => { + actions.createScheduledTask.mockResolvedValue(baseTask({})); + await mount([]); + click(findButton('New scheduled task')); + const prompt = document.querySelector('[role="textbox"]'); + if (!prompt) throw new Error('prompt editor not found'); + + act(() => { + prompt.textContent = 'x'.repeat(100_001); + prompt.dispatchEvent(new InputEvent('input', { bubbles: true })); + }); + await flush(); + click(findButton('Create')); + await flush(); + + expect(actions.createScheduledTask).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'x'.repeat(100_000), + }), + ); + }); + it('does not preserve empty contenteditable leftovers before an inserted reference', async () => { actions.createScheduledTask.mockResolvedValue(baseTask({})); diff --git a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx index 464ac14ae3..0501bf52cc 100644 --- a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx @@ -116,9 +116,10 @@ const MAX_SET_TIMEOUT_MS = 2_147_483_647; // back to the slow lane so a permanently-stuck task can't spin a 1 Hz GET loop. const PAST_DUE_FAST_RELOADS = 3; const OVERDUE_RELOAD_INTERVAL_MS = 30_000; +const MAX_PROMPT_LENGTH = 100_000; const AT_REFERENCE_UNSAFE_CHARS = /[^\p{L}\p{N}_./-]/gu; const PROMPT_REFERENCE_TOKEN = - /(^|[\s])(@(?:ext|mcp):(?:\\.|[^\s])+|\/[^\s/]+)/gu; + /(^|[\s])(@(?:ext|mcp):(?:\\.|[^\s])+|\/(?:\\.|[^\s/])+)(?=$|\s)/gu; const REFERENCE_PICKER_THEME_VARS = [ '--background', '--foreground', @@ -176,7 +177,7 @@ function promptReferenceItemFromToken( }; } if (token.startsWith('/')) { - const label = token.slice(1); + const label = unescapeReferenceText(token.slice(1)); if (!label) return null; return { id: `skill:${label}`, @@ -246,6 +247,24 @@ function setPromptEditorText(root: HTMLElement, text: string) { }); } +function normalizePromptEditor(root: HTMLElement): string { + let next = textFromPromptEditor(root); + if (next.length > MAX_PROMPT_LENGTH) { + next = next.slice(0, MAX_PROMPT_LENGTH); + setPromptEditorText(root, next); + } else if (next.trim().length === 0) { + clearPromptEditor(root); + next = ''; + } + return next; +} + +function insertPlainPromptText(root: HTMLElement, text: string) { + const remaining = MAX_PROMPT_LENGTH - textFromPromptEditor(root).length; + if (remaining <= 0) return; + document.execCommand('insertText', false, text.slice(0, remaining)); +} + function makePromptTagElement(item: PromptReferenceItem): HTMLElement { const tag = document.createElement('span'); tag.className = styles.promptTag; @@ -309,12 +328,14 @@ function insertPromptTagElement(root: HTMLElement, item: PromptReferenceItem) { function PromptReferenceEditor({ value, + label, placeholder, onChange, insertItem, onInserted, }: { value: string; + label: string; placeholder: string; onChange: (value: string) => void; insertItem: PromptReferenceItem | null; @@ -349,21 +370,27 @@ function PromptReferenceEditor({ className={styles.promptEditor} contentEditable role="textbox" + aria-label={label} aria-multiline="true" aria-placeholder={placeholder} onInput={(event) => { - let next = textFromPromptEditor(event.currentTarget); - if (next.trim().length === 0) { - clearPromptEditor(event.currentTarget); - next = ''; - } + const next = normalizePromptEditor(event.currentTarget); lastAppliedValueRef.current = next; onChange(next); }} onPaste={(event) => { event.preventDefault(); - const text = event.clipboardData.getData('text/plain'); - document.execCommand('insertText', false, text); + insertPlainPromptText( + event.currentTarget, + event.clipboardData.getData('text/plain'), + ); + }} + onDrop={(event) => { + event.preventDefault(); + insertPlainPromptText( + event.currentTarget, + event.dataTransfer.getData('text/plain'), + ); }} /> {value.trim().length === 0 && ( @@ -432,6 +459,7 @@ export function ScheduledTasksDialog({ // create/toggle/delete's reload must not overwrite the newer list with // stale data. Only the latest reload is allowed to apply its result. const reloadSeqRef = useRef(0); + const referenceLoadSeqRef = useRef(0); useEffect(() => { mountedRef.current = true; return () => { @@ -541,10 +569,22 @@ export function ScheduledTasksDialog({ }); }, []); + const resetReferenceState = useCallback(() => { + referenceLoadSeqRef.current += 1; + setReferenceKind(null); + setReferenceItems([]); + setReferenceLoading(false); + setReferenceError(null); + setPendingPromptTag(null); + setReferencePickerPosition(null); + setReferencePickerThemeVars({}); + }, []); + const loadReferences = useCallback( async (kind: PromptTagKind) => { + const seq = ++referenceLoadSeqRef.current; if (referenceKind === kind) { - setReferenceKind(null); + resetReferenceState(); return; } updateReferencePickerPosition(); @@ -553,54 +593,58 @@ export function ScheduledTasksDialog({ setReferenceError(null); setReferenceItems([]); try { + let items: PromptReferenceItem[]; if (kind === 'extension') { const status = await actions.loadExtensionsStatus(); - setReferenceItems( - (status.extensions ?? []) - .filter((extension) => extension.isActive) - .map((extension) => ({ - id: extension.id || extension.name, - kind, - label: extension.name, - description: extensionDescription(extension), - insertText: `@ext:${escapeAtReferenceText(extension.name)} `, - })), - ); + items = (status.extensions ?? []) + .filter((extension) => extension.isActive) + .map((extension) => ({ + id: extension.id || extension.name, + kind, + label: extension.name, + description: extensionDescription(extension), + insertText: `@ext:${escapeAtReferenceText(extension.name)} `, + })); } else if (kind === 'skill') { const status = await actions.loadSkillsStatus(); - setReferenceItems( - (status.skills ?? []) - .filter((skill) => skill.modelInvocable) - .map((skill) => ({ - id: skill.name, - kind, - label: skill.name, - description: skillDescription(skill), - insertText: `/${skill.name} `, - })), - ); + items = (status.skills ?? []) + .filter((skill) => skill.modelInvocable) + .map((skill) => ({ + id: skill.name, + kind, + label: skill.name, + description: skillDescription(skill), + insertText: `/${escapeAtReferenceText(skill.name)} `, + })); } else { const status = await actions.loadMcpStatus(); - setReferenceItems( - (status.servers ?? []) - .filter((server) => !server.disabled) - .map((server) => ({ - id: server.name, - kind, - label: server.name, - description: mcpDescription(server), - insertText: `@mcp:${escapeAtReferenceText(server.name)} `, - })), - ); + items = (status.servers ?? []) + .filter((server) => !server.disabled) + .map((server) => ({ + id: server.name, + kind, + label: server.name, + description: mcpDescription(server), + insertText: `@mcp:${escapeAtReferenceText(server.name)} `, + })); } + if (!mountedRef.current || seq !== referenceLoadSeqRef.current) return; + setReferenceItems(items); } catch (err) { - if (!mountedRef.current) return; + if (!mountedRef.current || seq !== referenceLoadSeqRef.current) return; setReferenceError(err instanceof Error ? err.message : String(err)); } finally { - if (mountedRef.current) setReferenceLoading(false); + if (mountedRef.current && seq === referenceLoadSeqRef.current) { + setReferenceLoading(false); + } } }, - [actions, referenceKind, updateReferencePickerPosition], + [ + actions, + referenceKind, + resetReferenceState, + updateReferencePickerPosition, + ], ); useEffect(() => { @@ -612,13 +656,13 @@ export function ScheduledTasksDialog({ const picker = referencePickerRef.current; if (popover?.contains(event.target as Node)) return; if (picker?.contains(event.target as Node)) return; - setReferenceKind(null); + resetReferenceState(); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); - setReferenceKind(null); + resetReferenceState(); } }; @@ -634,7 +678,7 @@ export function ScheduledTasksDialog({ window.removeEventListener('resize', handleReposition); window.removeEventListener('scroll', handleReposition, true); }; - }, [referenceKind, updateReferencePickerPosition]); + }, [referenceKind, resetReferenceState, updateReferencePickerPosition]); const resetForm = useCallback(() => { setName(''); @@ -644,12 +688,8 @@ export function ScheduledTasksDialog({ setFormError(null); setShowForm(false); setEditingId(null); - setReferenceKind(null); - setReferenceItems([]); - setReferenceError(null); - setPendingPromptTag(null); - setReferencePickerPosition(null); - }, []); + resetReferenceState(); + }, [resetReferenceState]); const openCreate = useCallback(() => { setEditingId(null); @@ -658,30 +698,25 @@ export function ScheduledTasksDialog({ setBuilder(DEFAULT_BUILDER); setRunMode('shared'); setFormError(null); - setReferenceKind(null); - setReferenceItems([]); - setReferenceError(null); - setPendingPromptTag(null); - setReferencePickerPosition(null); + resetReferenceState(); setShowForm(true); - }, []); + }, [resetReferenceState]); - const openEdit = useCallback((task: DaemonScheduledTask) => { - setEditingId(task.id); - setName(task.name ?? ''); - setPrompt(task.prompt); - // Reverse the cron back onto the pickers; an expression the pickers can't - // represent lands in the `custom` field, never silently rewritten. - setBuilder(parseCronToBuilder(task.cron)); - setRunMode(task.runMode ?? 'shared'); - setFormError(null); - setReferenceKind(null); - setReferenceItems([]); - setReferenceError(null); - setPendingPromptTag(null); - setReferencePickerPosition(null); - setShowForm(true); - }, []); + const openEdit = useCallback( + (task: DaemonScheduledTask) => { + setEditingId(task.id); + setName(task.name ?? ''); + setPrompt(task.prompt); + // Reverse the cron back onto the pickers; an expression the pickers can't + // represent lands in the `custom` field, never silently rewritten. + setBuilder(parseCronToBuilder(task.cron)); + setRunMode(task.runMode ?? 'shared'); + setFormError(null); + resetReferenceState(); + setShowForm(true); + }, + [resetReferenceState], + ); const handleSubmit = useCallback(async () => { const cron = buildCron(builder); @@ -693,6 +728,14 @@ export function ScheduledTasksDialog({ setFormError(t('scheduledTasks.error.emptyPrompt')); return; } + if (prompt.length > MAX_PROMPT_LENGTH) { + setFormError( + t('scheduledTasks.error.promptTooLong', { + max: MAX_PROMPT_LENGTH, + }), + ); + return; + } setSubmitting(true); setFormError(null); try { @@ -852,6 +895,8 @@ export function ScheduledTasksDialog({
event.preventDefault()} onClick={() => { setPendingPromptTag(item); @@ -965,6 +1012,7 @@ export function ScheduledTasksDialog({ + `Prompt exceeds ${v?.max ?? 100_000}-character limit`, 'scheduledTasks.error.toggleFailed': 'Failed to update task', 'scheduledTasks.error.deleteFailed': 'Failed to delete task', 'scheduledTasks.edit': 'Edit', @@ -2374,6 +2377,7 @@ const ZH: Messages = { 'scheduledTasks.reference.extension': '扩展', 'scheduledTasks.reference.skill': '技能', 'scheduledTasks.reference.mcp': 'MCP', + 'scheduledTasks.referencePicker': '引用选择器', 'scheduledTasks.reference.loading': '加载中…', 'scheduledTasks.reference.empty': '暂无可选项。', 'scheduledTasks.frequency': '频率', @@ -2409,6 +2413,8 @@ const ZH: Messages = { 'scheduledTasks.cancel': '取消', 'scheduledTasks.error.invalidSchedule': '计划无效', 'scheduledTasks.error.emptyPrompt': '提示词不能为空', + 'scheduledTasks.error.promptTooLong': (v) => + `提示词超过 ${v?.max ?? 100_000} 字符限制`, 'scheduledTasks.error.toggleFailed': '更新任务失败', 'scheduledTasks.error.deleteFailed': '删除任务失败', 'scheduledTasks.edit': '编辑',