fix(web-shell): address scheduled task reference review

This commit is contained in:
克竟 2026-07-09 21:36:02 +08:00
parent ee9fbf22fb
commit 7c3efc899f
3 changed files with 280 additions and 77 deletions

View file

@ -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<T = unknown>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<HTMLElement>('[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<HTMLElement>('[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' : '<b>html</b>',
},
});
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<HTMLElement>('[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({}));

View file

@ -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({
<div
ref={referencePickerRef}
className={styles.referencePicker}
role="listbox"
aria-label={t('scheduledTasks.referencePicker')}
style={
{
...referencePickerThemeVars,
@ -878,6 +923,8 @@ export function ScheduledTasksDialog({
key={`${item.kind}:${item.id}`}
type="button"
className={styles.referenceItem}
role="option"
aria-selected="false"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setPendingPromptTag(item);
@ -965,6 +1012,7 @@ export function ScheduledTasksDialog({
</span>
<PromptReferenceEditor
value={prompt}
label={t('scheduledTasks.prompt')}
placeholder={t('scheduledTasks.promptPlaceholder')}
onChange={setPrompt}
insertItem={pendingPromptTag}

View file

@ -679,6 +679,7 @@ const EN: Messages = {
'scheduledTasks.reference.extension': 'Extensions',
'scheduledTasks.reference.skill': 'Skills',
'scheduledTasks.reference.mcp': 'MCP',
'scheduledTasks.referencePicker': 'Reference picker',
'scheduledTasks.reference.loading': 'Loading…',
'scheduledTasks.reference.empty': 'No available items.',
'scheduledTasks.frequency': 'Frequency',
@ -715,6 +716,8 @@ const EN: Messages = {
'scheduledTasks.cancel': 'Cancel',
'scheduledTasks.error.invalidSchedule': 'Invalid schedule',
'scheduledTasks.error.emptyPrompt': 'Prompt is required',
'scheduledTasks.error.promptTooLong': (v) =>
`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': '编辑',