feat(web-shell): support mentions in scheduled task prompts

This commit is contained in:
克竟 2026-07-09 16:51:49 +08:00
parent 48e5d5d0d7
commit 755d505923
16 changed files with 1087 additions and 29 deletions

View file

@ -150,6 +150,55 @@ describe('scheduled-tasks routes', () => {
expect(list.body.tasks[0].id).toBe(res.body.id);
});
it('persists mentions metadata on create and list', async () => {
const mentions = [
{
kind: 'skill',
id: 'skill:loop',
label: 'loop',
serialized: '/loop',
},
];
const res = await create({
cron: '0 9 * * *',
prompt: '/loop summarize',
mentions,
});
expect(res.status).toBe(201);
expect(res.body.mentions).toEqual(mentions);
const list = await request(h.app).get('/scheduled-tasks');
expect(list.body.tasks[0].mentions).toEqual(mentions);
});
it('rejects malformed mentions', async () => {
const res = await create({
cron: '0 9 * * *',
prompt: 'p',
mentions: [{ kind: 'skill', id: '', serialized: '/loop' }],
});
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_mentions');
});
it('updates mentions via PATCH', async () => {
const created = await create({ cron: '0 9 * * *', prompt: 'p' });
const id = created.body.id as string;
const mentions = [
{
kind: 'extension',
id: 'extension:@ext:foo',
value: 'foo',
serialized: '@ext:foo',
},
];
const patch = await request(h.app)
.patch(`/scheduled-tasks/${id}`)
.send({ mentions });
expect(patch.status).toBe(200);
expect(patch.body.mentions).toEqual(mentions);
});
it('binds a created task to a freshly minted session', async () => {
const res = await create({ cron: '0 9 * * *', prompt: 'p' });
expect(res.status).toBe(201);

View file

@ -35,6 +35,7 @@ import {
stripTerminalControlSequences,
MAX_JOBS,
type DurableCronTask,
type CronTaskMention,
type CronTaskRun,
} from '@qwen-code/qwen-code-core';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
@ -119,6 +120,7 @@ interface ScheduledTaskView {
name: string | null;
cron: string;
prompt: string;
mentions?: CronTaskMention[];
recurring: boolean;
enabled: boolean;
createdAt: number;
@ -148,6 +150,7 @@ function toView(task: DurableCronTask): ScheduledTaskView {
typeof task.name === 'string' && task.name.length > 0 ? task.name : null,
cron: task.cron,
prompt: task.prompt,
...(Array.isArray(task.mentions) ? { mentions: task.mentions } : {}),
recurring: task.recurring,
// Absent enabled defaults to enabled — tool-created tasks never write it.
enabled: task.enabled !== false,
@ -180,6 +183,58 @@ function validateCron(cron: string): string | null {
}
}
function parseMentionsField(value: unknown): {
value?: CronTaskMention[];
error?: string;
} {
if (value === undefined) return {};
if (!Array.isArray(value)) {
return { error: '`mentions` must be an array' };
}
const mentions: CronTaskMention[] = [];
for (const entry of value) {
if (typeof entry !== 'object' || entry === null) {
return { error: '`mentions` entries must be objects' };
}
const mention = entry as Record<string, unknown>;
const kind = mention['kind'];
if (
kind !== 'skill' &&
kind !== 'mcp' &&
kind !== 'extension' &&
kind !== 'file'
) {
return {
error: '`mentions.kind` must be one of skill, mcp, extension, file',
};
}
const id = mention['id'];
const serialized = mention['serialized'];
if (typeof id !== 'string' || id.trim().length === 0) {
return { error: '`mentions.id` must be a non-empty string' };
}
if (typeof serialized !== 'string' || serialized.trim().length === 0) {
return { error: '`mentions.serialized` must be a non-empty string' };
}
const label = mention['label'];
if (label !== undefined && typeof label !== 'string') {
return { error: '`mentions.label` must be a string' };
}
const valueField = mention['value'];
if (valueField !== undefined && typeof valueField !== 'string') {
return { error: '`mentions.value` must be a string' };
}
mentions.push({
kind,
id: id.trim(),
serialized: serialized.trim(),
...(typeof label === 'string' ? { label: label.trim() } : {}),
...(typeof valueField === 'string' ? { value: valueField.trim() } : {}),
});
}
return { value: mentions };
}
/**
* A canonical string for a cron expression's *effective* schedule, so two
* expressions that fire identically compare equal regardless of surface form
@ -280,6 +335,14 @@ export function registerScheduledTasksRoutes(
return;
}
const mentionsResult = parseMentionsField(body['mentions']);
if (mentionsResult.error) {
res
.status(400)
.json({ error: mentionsResult.error, code: 'invalid_mentions' });
return;
}
if (
body['recurring'] !== undefined &&
typeof body['recurring'] !== 'boolean'
@ -364,6 +427,9 @@ export function registerScheduledTasksRoutes(
id: generateCronTaskId(),
cron,
prompt,
...(mentionsResult.value !== undefined
? { mentions: mentionsResult.value }
: {}),
recurring,
createdAt: now,
// Pin to the creation minute so the scheduler can't fire during the
@ -480,6 +546,16 @@ export function registerScheduledTasksRoutes(
patch.name = nameResult.value;
}
}
if ('mentions' in body) {
const mentionsResult = parseMentionsField(body['mentions']);
if (mentionsResult.error) {
res
.status(400)
.json({ error: mentionsResult.error, code: 'invalid_mentions' });
return;
}
patch.mentions = mentionsResult.value ?? [];
}
if ('recurring' in body) {
if (typeof body['recurring'] !== 'boolean') {
res.status(400).json({

View file

@ -207,7 +207,11 @@ export {
} from './services/chatCompressionService.js';
export * from './services/chatRecordingService.js';
export * from './services/cronScheduler.js';
export type { DurableCronTask, CronTaskRun } from './services/cronTasksFile.js';
export type {
DurableCronTask,
CronTaskMention,
CronTaskRun,
} from './services/cronTasksFile.js';
export {
readCronTasks,
updateCronTasks,

View file

@ -145,6 +145,27 @@ describe('cronTasksFile', () => {
expect(result).toEqual([task]);
});
it('round-trips optional mentions metadata', async () => {
const task = makeTask({
mentions: [
{
kind: 'skill',
id: 'skill:loop',
label: 'loop',
serialized: '/loop',
},
{
kind: 'mcp',
id: 'mcp:@server:resource',
value: 'resource',
serialized: '@server:resource',
},
],
});
await writeCronTasks(tmpDir, [task]);
expect(await readCronTasks(tmpDir)).toEqual([task]);
});
it('accepts legacy tasks with no name/enabled fields', async () => {
// A task written before the fields existed must still read back.
const legacy = makeTask();
@ -170,6 +191,19 @@ describe('cronTasksFile', () => {
await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/);
});
it('rejects a task whose mentions are malformed', async () => {
await seedTasksFile(
tmpDir,
JSON.stringify([
{
...makeTask(),
mentions: [{ kind: 'skill', id: '', serialized: '/loop' }],
},
]),
);
await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/);
});
it('round-trips the optional runs history', async () => {
const task = makeTask({
lastFiredAt: 1718000300000,

View file

@ -51,10 +51,19 @@ export interface CronTaskRun {
* `lastFiredAt`, so appending a capped run adds no extra write, only bytes). */
export const MAX_TASK_RUNS = 20;
export interface CronTaskMention {
kind: 'skill' | 'mcp' | 'extension' | 'file';
id: string;
label?: string;
value?: string;
serialized: string;
}
export interface DurableCronTask {
id: string;
cron: string;
prompt: string;
mentions?: CronTaskMention[];
recurring: boolean;
createdAt: number;
lastFiredAt: number | null;
@ -371,6 +380,28 @@ function isValidRuns(value: unknown): value is CronTaskRun[] {
});
}
function isValidMentions(value: unknown): value is CronTaskMention[] {
if (!Array.isArray(value)) return false;
return value.every((entry) => {
if (typeof entry !== 'object' || entry === null) return false;
const mention = entry as Record<string, unknown>;
return (
(mention['kind'] === 'skill' ||
mention['kind'] === 'mcp' ||
mention['kind'] === 'extension' ||
mention['kind'] === 'file') &&
typeof mention['id'] === 'string' &&
mention['id'].length > 0 &&
(mention['label'] === undefined ||
typeof mention['label'] === 'string') &&
(mention['value'] === undefined ||
typeof mention['value'] === 'string') &&
typeof mention['serialized'] === 'string' &&
mention['serialized'].length > 0
);
});
}
function isValidTask(value: unknown): value is DurableCronTask {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
@ -378,6 +409,7 @@ function isValidTask(value: unknown): value is DurableCronTask {
typeof obj['id'] === 'string' &&
typeof obj['cron'] === 'string' &&
typeof obj['prompt'] === 'string' &&
(obj['mentions'] === undefined || isValidMentions(obj['mentions'])) &&
typeof obj['recurring'] === 'boolean' &&
isFiniteTimestamp(obj['createdAt']) &&
(obj['lastFiredAt'] === null || isFiniteTimestamp(obj['lastFiredAt'])) &&

View file

@ -4805,6 +4805,7 @@ export function App({
</div>
<div className={styles.fullPageBody}>
<ScheduledTasksDialog
skills={loadedSkills}
onRunPrompt={runTaskManually}
onCreateViaChat={() => {
// Start a FRESH session and jump to it so the task-

View file

@ -24,6 +24,7 @@ const AT_PANEL_THEME_VARS = [
'--muted-foreground',
'--chat-editor-border-color',
'--font-sans',
'--web-shell-popover-z-index',
];
export function AtMentionPanel({
@ -34,6 +35,8 @@ export function AtMentionPanel({
onAccept,
onBack,
onSearch,
placement = 'above',
portal = true,
}: {
menu: AtMentionMenuState;
anchorRef: RefObject<HTMLElement | null>;
@ -42,13 +45,16 @@ export function AtMentionPanel({
onAccept: (index?: number) => boolean;
onBack: () => boolean;
onSearch: (query: string) => boolean;
placement?: 'above' | 'below';
portal?: boolean;
}) {
const { t } = useI18n();
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [anchorRect, setAnchorRect] = useState<{
left: number;
bottom: number;
bottom: number | null;
top: number | null;
width: number;
} | null>(null);
const [themeVars, setThemeVars] = useState<CSSProperties>({});
@ -104,7 +110,9 @@ export function AtMentionPanel({
12,
Math.min(rect.left + 16, window.innerWidth - panelWidth - 12),
),
bottom: window.innerHeight - rect.top + 8,
bottom:
placement === 'above' ? window.innerHeight - rect.top + 8 : null,
top: placement === 'below' ? rect.bottom + 8 : null,
width: rect.width,
};
setAnchorRect((prev) => {
@ -112,6 +120,7 @@ export function AtMentionPanel({
prev &&
prev.left === next.left &&
prev.bottom === next.bottom &&
prev.top === next.top &&
prev.width === next.width
) {
return prev;
@ -140,7 +149,7 @@ export function AtMentionPanel({
window.removeEventListener('resize', scheduleUpdatePosition);
window.removeEventListener('scroll', scheduleUpdatePosition, true);
};
}, [anchorRef, panelRef]);
}, [anchorRef, panelRef, placement]);
const rows =
menu.level === 'categories'
@ -184,8 +193,11 @@ export function AtMentionPanel({
? `at-mention-option-${menu.selectedIndex}`
: undefined;
return createPortal(
<div className={styles.atPortalLayer} style={themeVars}>
const panel = (
<div
className={portal ? styles.atPortalLayer : undefined}
style={themeVars}
>
<div
ref={panelRef}
className={styles.atPanel}
@ -193,9 +205,16 @@ export function AtMentionPanel({
style={
{
...themeVars,
left: anchorRect.left,
bottom: anchorRect.bottom,
'--at-anchor-width': `${anchorRect.width}px`,
left: portal ? anchorRect.left : 0,
...(portal
? anchorRect.bottom !== null
? { bottom: anchorRect.bottom }
: { top: anchorRect.top ?? 0 }
: placement === 'above'
? { bottom: 'calc(100% + 8px)' }
: { top: 'calc(100% + 8px)' }),
['--at-anchor-width' as string]: `${anchorRect.width}px`,
...(portal ? {} : { position: 'absolute' }),
} as CSSProperties
}
role="region"
@ -332,7 +351,8 @@ export function AtMentionPanel({
)}
</div>
</div>
</div>,
document.body,
</div>
);
return portal ? createPortal(panel, document.body) : panel;
}

View file

@ -41,13 +41,13 @@
--font-sans:
-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--radius: 6px;
--dialog-overlay: rgba(10, 10, 11, 0.32);
--dialog-overlay: rgba(10, 10, 11, 0.52);
}
.backdrop {
position: fixed;
inset: 0;
z-index: var(--web-shell-dialog-backdrop-z-index, 1000);
z-index: var(--web-shell-dialog-backdrop-z-index, 3000);
display: flex;
align-items: center;
justify-content: center;

View file

@ -0,0 +1,501 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { EditorView } from '@codemirror/view';
import {
useOptionalWorkspace,
type DaemonWorkspaceMcpServerStatus,
} from '@qwen-code/webui/daemon-react-sdk';
import type { SkillInfo } from '../../completions/slashCompletion';
import { AtMentionPanel } from '../AtMentionPanel';
import {
clearInlineTagsEffect,
getInlineComposerTags,
useComposerCore,
} from '../../hooks/useComposerCore';
import type {
WebShellAtProvider,
WebShellComposerTag,
} from '../../customization';
import { getComposerTagIconUrl } from '../composerTagIcons';
import { cssUrlVar } from '../../utils/cssUrlVar';
import { useI18n } from '../../i18n';
import styles from './ScheduledTasksDialog.module.css';
const PROMPT_EDITOR_THEME = {
'&': {
fontSize: '13px',
background: 'transparent',
border: 'none',
borderRadius: '0',
minHeight: '96px',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-scroller': {
minHeight: '96px',
maxHeight: '240px',
overflowX: 'hidden',
overflowY: 'auto',
},
'.cm-content': {
padding: '8px 10px',
fontFamily: 'inherit',
color: 'var(--foreground)',
caretColor: 'var(--primary)',
fontSize: '13px',
lineHeight: '1.5',
},
'.cm-line': {
padding: '0',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
},
'.cm-cursor': {
borderLeftColor: 'var(--primary)',
borderLeftWidth: '2px',
},
} satisfies Parameters<typeof EditorView.theme>[0];
interface ScheduledTaskPromptEditorProps {
prompt: string;
mentions: WebShellComposerTag[];
skills: SkillInfo[];
onChange: (next: { prompt: string; mentions: WebShellComposerTag[] }) => void;
}
type PickerCategory = 'skill' | 'extension' | 'mcp';
function toSkillProvider(skills: SkillInfo[]): WebShellAtProvider {
return {
id: 'skills',
label: 'Skills',
description: 'Run an available skill',
order: 1,
async search({ query }) {
const lower = query.toLowerCase();
return skills
.filter((skill) => {
return (
skill.name.toLowerCase().includes(lower) ||
skill.description.toLowerCase().includes(lower)
);
})
.map((skill) => ({
id: `skill:${skill.name}`,
label: skill.name,
description: skill.description,
insertText: `/${skill.name} `,
composerTag: {
id: `skill:${skill.name}`,
kind: 'skill',
label: skill.name,
serialized: `/${skill.name}`,
},
}));
},
};
}
function mergeTags(
current: readonly WebShellComposerTag[],
incoming: readonly WebShellComposerTag[],
): WebShellComposerTag[] {
const next = [...current];
for (const tag of incoming) {
const existingIndex = next.findIndex((item) => item.id === tag.id);
if (existingIndex >= 0) {
next[existingIndex] = tag;
} else {
next.push(tag);
}
}
return next;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripInlineTagText(
text: string,
tags: readonly WebShellComposerTag[],
): string {
let next = text;
for (const tag of tags) {
const serialized = tag.serialized?.trim();
if (!serialized) continue;
const pattern = new RegExp(`(^|\\s)${escapeRegExp(serialized)}(?=\\s|$)`);
next = next.replace(pattern, (_match, leadingWs: string) => leadingWs);
}
return next
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function renderTagContent(tag: WebShellComposerTag) {
const iconUrl = getComposerTagIconUrl(tag.kind);
const label = tag.label?.trim() ?? '';
const value = tag.value?.trim() ?? '';
return (
<>
{iconUrl && (
<span
className={styles.promptTagIcon}
style={cssUrlVar('--composer-tag-icon-url', iconUrl)}
aria-hidden="true"
/>
)}
<span className={styles.promptTagLabel}>{value || label || tag.id}</span>
</>
);
}
export function ScheduledTaskPromptEditor({
prompt,
mentions,
skills,
onChange,
}: ScheduledTaskPromptEditorProps) {
const { t } = useI18n();
const workspace = useOptionalWorkspace();
const anchorRef = useRef<HTMLDivElement | null>(null);
const atPanelRef = useRef<HTMLDivElement | null>(null);
const pickerRef = useRef<HTMLDivElement | null>(null);
const [pickerCategory, setPickerCategory] = useState<PickerCategory | null>(
null,
);
const pickerOpen = pickerCategory !== null;
const [extensions, setExtensions] = useState<
Array<{ name: string; displayName?: string }>
>([]);
const [mcpServers, setMcpServers] = useState<
DaemonWorkspaceMcpServerStatus[]
>([]);
const skillProvider = useMemo(() => toSkillProvider(skills), [skills]);
const core = useComposerCore({
onSubmit: () => false,
disabled: false,
placeholderText: t('scheduledTasks.promptPlaceholder'),
commands: [],
skills: [],
currentMode: 'default',
dialogOpen: true,
atProviders: skills.length > 0 ? [skillProvider] : [],
editorTheme: PROMPT_EDITOR_THEME,
composerInput: {
text: prompt,
tags: mentions,
tagPlacement: 'top',
},
composerInputVersion: 1,
});
const syncPromptOnly = (
nextPrompt: string,
nextMentions = core.composerTags,
) => {
onChange({ prompt: nextPrompt, mentions: nextMentions });
};
const normalizeInlineMentions = () => {
const view = core.viewRef.current;
const inlineTags = view ? getInlineComposerTags(view) : [];
const nextPrompt = stripInlineTagText(core.getText(), inlineTags);
if (inlineTags.length > 0 && view) {
view.dispatch({ effects: clearInlineTagsEffect.of() });
core.replaceEditorText(nextPrompt);
core.addTags(inlineTags);
const merged = mergeTags(core.composerTags, inlineTags);
onChange({ prompt: nextPrompt, mentions: merged });
return;
}
syncPromptOnly(nextPrompt);
};
const handleAcceptMention = (index?: number) => {
const accepted = core.acceptAtCompletion(index);
if (accepted) {
queueMicrotask(() => {
normalizeInlineMentions();
core.focus();
});
}
return accepted;
};
useEffect(() => {
if (!pickerOpen) return;
const onPointerDown = (event: MouseEvent) => {
const target = event.target;
if (target instanceof Node && pickerRef.current?.contains(target)) {
return;
}
setPickerCategory(null);
};
window.addEventListener('mousedown', onPointerDown);
return () => window.removeEventListener('mousedown', onPointerDown);
}, [pickerOpen]);
useEffect(() => {
if (!workspace?.actions || !pickerOpen) return;
void workspace.actions.loadExtensionsStatus().then((status) => {
setExtensions(
status.extensions
.filter((extension) => extension.isActive)
.map((extension) => ({
name: extension.name,
displayName: extension.displayName,
})),
);
});
void workspace.actions.loadMcpStatus().then((status) => {
setMcpServers(status.servers.filter((server) => !server.disabled));
});
}, [pickerOpen, workspace]);
const insertTag = (tag: WebShellComposerTag) => {
core.addTags([tag]);
const nextMentions = mergeTags(core.composerTags, [tag]);
const nextPrompt = stripInlineTagText(core.getText(), []);
onChange({ prompt: nextPrompt, mentions: nextMentions });
setPickerCategory(null);
queueMicrotask(() => core.focus());
};
const removeTag = (id: string) => {
const nextMentions = core.composerTags.filter(
(tag) => tag.id !== id || tag.removable === false,
);
core.removeTopTag(id);
onChange({
prompt: stripInlineTagText(core.getText(), []),
mentions: nextMentions,
});
queueMicrotask(() => core.focus());
};
return (
<div className={styles.promptEditorWrap}>
<div
ref={anchorRef}
className={styles.promptEditor}
style={{
['--web-shell-popover-z-index' as string]: '3050',
}}
onMouseDown={(event) => {
const target = event.target as HTMLElement | null;
if (target?.closest('button')) return;
queueMicrotask(() => core.focus());
}}
>
<div className={styles.promptEditorContent}>
{core.composerTags.map((tag) => (
<span key={tag.id} className={styles.promptTag}>
{renderTagContent(tag)}
{tag.removable !== false && (
<button
type="button"
className={styles.promptTagRemove}
aria-label={`Remove ${tag.label ?? tag.value ?? tag.id}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => removeTag(tag.id)}
>
×
</button>
)}
</span>
))}
<div
ref={core.containerRef}
className={styles.promptEditorInput}
onBlur={() => queueMicrotask(normalizeInlineMentions)}
onKeyUp={() => queueMicrotask(normalizeInlineMentions)}
onMouseUp={() => queueMicrotask(normalizeInlineMentions)}
/>
</div>
<div className={styles.promptEditorToolbar}>
<button
type="button"
className={`${styles.promptEditorAction} ${
pickerCategory === 'extension'
? styles.promptEditorActionActive
: ''
}`}
onClick={() =>
setPickerCategory((current) =>
current === 'extension' ? null : 'extension',
)
}
aria-label="Insert extension mention"
>
<span
className={styles.promptEditorActionIcon}
style={cssUrlVar(
'--composer-tag-icon-url',
getComposerTagIconUrl('extension') ?? '',
)}
aria-hidden="true"
/>
<span className={styles.promptEditorActionLabel}></span>
</button>
<button
type="button"
className={`${styles.promptEditorAction} ${
pickerCategory === 'skill' ? styles.promptEditorActionActive : ''
}`}
onClick={() =>
setPickerCategory((current) =>
current === 'skill' ? null : 'skill',
)
}
aria-label="Insert skill mention"
>
<span
className={styles.promptEditorActionIcon}
style={cssUrlVar(
'--composer-tag-icon-url',
getComposerTagIconUrl('skill') ?? '',
)}
aria-hidden="true"
/>
<span className={styles.promptEditorActionLabel}></span>
</button>
<button
type="button"
className={`${styles.promptEditorAction} ${
pickerCategory === 'mcp' ? styles.promptEditorActionActive : ''
}`}
onClick={() =>
setPickerCategory((current) => (current === 'mcp' ? null : 'mcp'))
}
aria-label="Insert MCP mention"
>
<span
className={styles.promptEditorActionIcon}
style={cssUrlVar(
'--composer-tag-icon-url',
getComposerTagIconUrl('mcp') ?? '',
)}
aria-hidden="true"
/>
<span className={styles.promptEditorActionLabel}>MCP</span>
</button>
</div>
{pickerOpen && (
<div ref={pickerRef} className={styles.promptPicker}>
<div className={styles.promptPickerCols}>
<div className={styles.promptPickerCol}>
<button
type="button"
className={styles.promptPickerItem}
onMouseEnter={() => setPickerCategory('skill')}
onClick={() => setPickerCategory('skill')}
>
<span className={styles.promptPickerLabel}></span>
</button>
<button
type="button"
className={styles.promptPickerItem}
onMouseEnter={() => setPickerCategory('extension')}
onClick={() => setPickerCategory('extension')}
>
<span className={styles.promptPickerLabel}></span>
</button>
<button
type="button"
className={styles.promptPickerItem}
onMouseEnter={() => setPickerCategory('mcp')}
onClick={() => setPickerCategory('mcp')}
>
<span className={styles.promptPickerLabel}></span>
</button>
</div>
<div className={styles.promptPickerCol}>
{pickerCategory === 'skill' &&
skills.map((skill) => (
<button
key={skill.name}
type="button"
className={styles.promptPickerItem}
onClick={() =>
insertTag({
id: `skill:${skill.name}`,
kind: 'skill',
label: skill.name,
serialized: `/${skill.name}`,
})
}
>
<span className={styles.promptPickerLabel}>
{skill.name}
</span>
</button>
))}
{pickerCategory === 'extension' &&
extensions.map((extension) => (
<button
key={extension.name}
type="button"
className={styles.promptPickerItem}
onClick={() =>
insertTag({
id: `extension:@ext:${extension.name}`,
kind: 'extension',
value: extension.name,
label: extension.displayName,
serialized: `@ext:${extension.name}`,
})
}
>
<span className={styles.promptPickerLabel}>
{extension.name}
</span>
</button>
))}
{pickerCategory === 'mcp' &&
mcpServers.map((server) => (
<button
key={server.name}
type="button"
className={styles.promptPickerItem}
onClick={() =>
insertTag({
id: `mcp:@mcp:${server.name}`,
kind: 'mcp',
value: server.name,
serialized: `@mcp:${server.name}`,
})
}
>
<span className={styles.promptPickerLabel}>
{server.name}
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
{core.atMenu && (
<AtMentionPanel
menu={core.atMenu}
anchorRef={anchorRef}
panelRef={atPanelRef}
onSelect={core.selectAtCompletion}
onAccept={handleAcceptMention}
onBack={() => Boolean(core.backAtCategories())}
onSearch={core.updateAtSearch}
placement="below"
portal={false}
/>
)}
</div>
);
}

View file

@ -118,6 +118,203 @@
line-height: 1.5;
}
.promptEditorWrap {
min-width: 0;
}
.promptEditor {
position: relative;
min-width: 0;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--background);
}
.promptEditor:focus-within {
outline: 2px solid var(--primary);
outline-offset: -1px;
}
.promptEditorInput {
min-width: 220px;
flex: 1 1 220px;
}
.promptEditorInput :global(.cm-editor) {
min-height: 120px;
}
.promptEditorInput :global(.cm-scroller) {
min-height: 120px;
}
.promptEditorInput :global(.cm-content) {
padding: 0;
}
.promptEditorContent {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
align-content: flex-start;
align-items: flex-start;
gap: 8px;
min-height: 160px;
padding: 12px;
cursor: text;
}
.promptTag {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
max-width: 100%;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--muted) 78%, white 22%);
color: var(--foreground);
}
.promptTagIcon {
display: inline-block;
width: 14px;
height: 14px;
flex: 0 0 auto;
background: currentColor;
mask: var(--composer-tag-icon-url) center / contain no-repeat;
-webkit-mask: var(--composer-tag-icon-url) center / contain no-repeat;
}
.promptTagLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.promptTagRemove {
border: 0;
background: transparent;
color: var(--muted-foreground);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.promptEditorToolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 0 12px 12px;
}
.promptEditorAction {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--muted);
color: var(--muted-foreground);
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
.promptEditorAction:hover,
.promptEditorAction:focus-visible,
.promptEditorActionActive {
background: color-mix(in srgb, var(--muted) 72%, white 28%);
color: var(--foreground);
border-color: color-mix(in srgb, var(--border) 78%, var(--foreground) 22%);
outline: none;
}
.promptEditorActionIcon {
display: inline-block;
width: 14px;
height: 14px;
flex: 0 0 auto;
background: currentColor;
mask: var(--composer-tag-icon-url) center / contain no-repeat;
-webkit-mask: var(--composer-tag-icon-url) center / contain no-repeat;
}
.promptEditorActionLabel {
font-size: 12px;
font-weight: 500;
}
.promptPicker {
position: absolute;
left: 0;
bottom: 48px;
z-index: 3020;
width: min(360px, calc(100vw - 80px));
border: 1px solid var(--border);
border-radius: 12px;
background: var(--background);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
overflow: hidden;
}
.promptPickerCols {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
height: min(220px, calc(100vh - 260px));
}
.promptPickerCol {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow-y: auto;
}
.promptPickerCol:first-child {
border-right: 1px solid var(--border);
}
.promptPickerItem {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 10px 12px;
background: transparent;
border: 0;
text-align: left;
color: var(--foreground);
cursor: pointer;
}
.promptPickerItem:hover {
background: var(--muted);
}
.promptPickerLabel {
font-size: 14px;
font-weight: 600;
}
.promptPickerDesc {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
font-size: 12px;
line-height: 1.35;
color: var(--muted-foreground);
}
.input:focus,
.textarea:focus,
.select:focus {

View file

@ -20,6 +20,13 @@ interface MockTask {
name: string | null;
cron: string;
prompt: string;
mentions?: Array<{
kind: 'skill' | 'mcp' | 'extension' | 'file';
id: string;
label?: string;
value?: string;
serialized: string;
}>;
recurring: boolean;
enabled: boolean;
createdAt: number;
@ -41,6 +48,7 @@ const { actions } = vi.hoisted(() => ({
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useWorkspaceActions: () => actions,
useOptionalWorkspace: () => ({ actions }),
}));
const { ScheduledTasksDialog } = await import('./ScheduledTasksDialog');
@ -70,6 +78,7 @@ async function mount(
root!.render(
<I18nProvider language="en">
<ScheduledTasksDialog
skills={[]}
onRunPrompt={opts.onRunPrompt ?? vi.fn()}
onCreateViaChat={vi.fn()}
onOpenSession={opts.onOpenSession}
@ -137,11 +146,10 @@ describe('ScheduledTasksDialog editing', () => {
// The cron reverses onto the structured pickers (weekdays @ 12:30) and the
// name/prompt are prefilled — not left blank as they would be for create.
const name = document.querySelector<HTMLInputElement>('input[type="text"]');
const prompt = document.querySelector<HTMLTextAreaElement>('textarea');
const frequency = document.querySelector<HTMLSelectElement>('select');
const time = document.querySelector<HTMLInputElement>('input[type="time"]');
expect(name?.value).toBe('Digest');
expect(prompt?.value).toBe('summarize the day');
expect(document.body.textContent).toContain('summarize the day');
expect(frequency?.value).toBe('weekdays');
expect(time?.value).toBe('12:30');
@ -153,6 +161,7 @@ describe('ScheduledTasksDialog editing', () => {
expect(actions.updateScheduledTask).toHaveBeenCalledWith('t1', {
cron: '30 12 * * 1-5',
prompt: 'summarize the day',
mentions: [],
name: 'Digest',
});
expect(actions.createScheduledTask).not.toHaveBeenCalled();
@ -173,6 +182,41 @@ describe('ScheduledTasksDialog editing', () => {
expect.objectContaining({ cron: '0 9 * * 1,3,5' }),
);
});
it('round-trips stored mentions metadata on save', async () => {
await mount([
baseTask({
prompt: '/loop summarize the day',
mentions: [
{
kind: 'skill',
id: 'skill:loop',
label: 'loop',
serialized: '/loop',
},
],
}),
]);
click(document.querySelector('[aria-label="Edit"]'));
click(findButton('Save'));
await flush();
expect(actions.updateScheduledTask).toHaveBeenCalledWith(
't1',
expect.objectContaining({
prompt: '/loop\n\nsummarize the day',
mentions: [
{
kind: 'skill',
id: 'skill:loop',
label: 'loop',
serialized: '/loop',
},
],
}),
);
});
});
describe('ScheduledTasksDialog run history', () => {

View file

@ -9,7 +9,10 @@ import {
useWorkspaceActions,
type DaemonScheduledTask,
type DaemonScheduledTaskRun,
type DaemonScheduledTaskMention,
} from '@qwen-code/webui/daemon-react-sdk';
import type { SkillInfo } from '../../completions/slashCompletion';
import type { WebShellComposerTag } from '../../customization';
import { useI18n } from '../../i18n';
import { DialogShell } from './DialogShell';
import {
@ -24,6 +27,8 @@ import {
type TranslateFn,
} from './scheduledTasksSchedule';
import styles from './ScheduledTasksDialog.module.css';
import { ScheduledTaskPromptEditor } from './ScheduledTaskPromptEditor';
import { buildComposerPrompt } from '../../hooks/useComposerCore';
/** Localized absolute timestamp, resilient to a bad epoch value. */
function safeLocaleString(ms: number): string {
@ -46,6 +51,7 @@ function describeRun(run: DaemonScheduledTaskRun, t: TranslateFn): string {
}
interface ScheduledTasksDialogProps {
skills: SkillInfo[];
/** Manual "run now": execute the task's prompt in its bound session (so it
* lands in the same transcript as its scheduled runs), or in the current
* session for an unbound task. The App wiring switches to that session. */
@ -84,7 +90,61 @@ const MAX_SET_TIMEOUT_MS = 2_147_483_647;
const PAST_DUE_FAST_RELOADS = 3;
const OVERDUE_RELOAD_INTERVAL_MS = 30_000;
function mentionToTag(
mention: DaemonScheduledTaskMention,
): WebShellComposerTag {
return {
id: mention.id,
kind: mention.kind,
label: mention.label,
value: mention.value,
serialized: mention.serialized,
};
}
function tagToMention(
mention: WebShellComposerTag,
): DaemonScheduledTaskMention | null {
const kind = mention.kind;
if (
kind !== 'skill' &&
kind !== 'mcp' &&
kind !== 'extension' &&
kind !== 'file'
) {
return null;
}
const serialized = mention.serialized?.trim();
if (!serialized) return null;
return {
kind: kind as DaemonScheduledTaskMention['kind'],
id: mention.id,
...(mention.label ? { label: mention.label } : {}),
...(mention.value ? { value: mention.value } : {}),
serialized,
};
}
function stripLeadingMentionText(
prompt: string,
mentions: DaemonScheduledTaskMention[],
): string {
if (mentions.length === 0) return prompt;
const prefix = mentions
.map((mention) => mention.serialized.trim())
.filter((serialized) => serialized.length > 0)
.join(' ');
if (!prefix) return prompt;
const trimmed = prompt.trim();
if (trimmed === prefix) return '';
if (trimmed.startsWith(`${prefix} `)) {
return trimmed.slice(prefix.length + 1);
}
return prompt;
}
export function ScheduledTasksDialog({
skills,
onRunPrompt,
onCreateViaChat,
onOpenSession,
@ -107,6 +167,7 @@ export function ScheduledTasksDialog({
const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState('');
const [prompt, setPrompt] = useState('');
const [mentions, setMentions] = useState<WebShellComposerTag[]>([]);
const [builder, setBuilder] = useState<BuilderState>(DEFAULT_BUILDER);
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
@ -198,6 +259,7 @@ export function ScheduledTasksDialog({
const resetForm = useCallback(() => {
setName('');
setPrompt('');
setMentions([]);
setBuilder(DEFAULT_BUILDER);
setFormError(null);
setShowForm(false);
@ -208,6 +270,7 @@ export function ScheduledTasksDialog({
setEditingId(null);
setName('');
setPrompt('');
setMentions([]);
setBuilder(DEFAULT_BUILDER);
setFormError(null);
setShowForm(true);
@ -216,7 +279,9 @@ export function ScheduledTasksDialog({
const openEdit = useCallback((task: DaemonScheduledTask) => {
setEditingId(task.id);
setName(task.name ?? '');
setPrompt(task.prompt);
const taskMentions = task.mentions ?? [];
setPrompt(stripLeadingMentionText(task.prompt, taskMentions));
setMentions(taskMentions.map(mentionToTag));
// 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));
@ -230,26 +295,34 @@ export function ScheduledTasksDialog({
setFormError(t('scheduledTasks.error.invalidSchedule'));
return;
}
if (prompt.trim().length === 0) {
const executablePrompt = buildComposerPrompt(prompt.trim(), mentions);
if (executablePrompt.trim().length === 0) {
setFormError(t('scheduledTasks.error.emptyPrompt'));
return;
}
setSubmitting(true);
setFormError(null);
try {
const scheduledMentions = mentions
.map(tagToMention)
.filter(
(mention): mention is DaemonScheduledTaskMention => mention !== null,
);
if (editingId) {
// Update only the editable fields; `recurring`/`enabled` are omitted so
// the PATCH leaves them unchanged (recurring isn't in this form, and
// enabled is driven by the card toggle). Empty name clears it.
await actions.updateScheduledTask(editingId, {
cron,
prompt: prompt.trim(),
prompt: executablePrompt,
mentions: scheduledMentions,
name: name.trim() || null,
});
} else {
await actions.createScheduledTask({
cron,
prompt: prompt.trim(),
prompt: executablePrompt,
mentions: scheduledMentions,
name: name.trim() || null,
recurring: true,
enabled: true,
@ -264,7 +337,17 @@ export function ScheduledTasksDialog({
} finally {
if (mountedRef.current) setSubmitting(false);
}
}, [actions, builder, editingId, name, prompt, reload, resetForm, t]);
}, [
actions,
builder,
editingId,
mentions,
name,
prompt,
reload,
resetForm,
t,
]);
const handleToggle = useCallback(
async (task: DaemonScheduledTask) => {
@ -423,20 +506,22 @@ export function ScheduledTasksDialog({
/>
</label>
<label className={styles.field}>
<div className={styles.field}>
<span className={styles.fieldLabel}>
{t('scheduledTasks.prompt')}
<span className={styles.required}>*</span>
</span>
<textarea
className={styles.textarea}
value={prompt}
rows={4}
maxLength={100_000}
placeholder={t('scheduledTasks.promptPlaceholder')}
onChange={(e) => setPrompt(e.target.value)}
<ScheduledTaskPromptEditor
key={editingId ?? 'new'}
prompt={prompt}
mentions={mentions}
skills={skills}
onChange={({ prompt: nextPrompt, mentions: nextMentions }) => {
setPrompt(nextPrompt);
setMentions(nextMentions);
}}
/>
</label>
</div>
<div className={styles.scheduleRow}>
<label className={styles.field}>

View file

@ -282,6 +282,8 @@ export type {
DaemonGlobResult,
/** A durable scheduled task (cron) as returned by the daemon. */
DaemonScheduledTask,
/** Mention metadata attached to a scheduled task prompt. */
DaemonScheduledTaskMention,
/** One recorded fire in a scheduled task's run history. */
DaemonScheduledTaskRun,
/** Request body for creating a scheduled task. */

View file

@ -88,6 +88,7 @@ export type {
DaemonGlobOptions,
DaemonGlobResult,
DaemonScheduledTask,
DaemonScheduledTaskMention,
DaemonScheduledTaskRun,
DaemonCreateScheduledTaskRequest,
DaemonUpdateScheduledTaskRequest,

View file

@ -17,6 +17,7 @@ export type {
DaemonGlobOptions,
DaemonGlobResult,
DaemonScheduledTask,
DaemonScheduledTaskMention,
DaemonScheduledTaskRun,
DaemonCreateScheduledTaskRequest,
DaemonUpdateScheduledTaskRequest,

View file

@ -173,11 +173,20 @@ export interface DaemonScheduledTaskRun {
sessionId?: string;
}
export interface DaemonScheduledTaskMention {
kind: 'skill' | 'mcp' | 'extension' | 'file';
id: string;
label?: string;
value?: string;
serialized: string;
}
export interface DaemonScheduledTask {
id: string;
name: string | null;
cron: string;
prompt: string;
mentions?: DaemonScheduledTaskMention[];
recurring: boolean;
enabled: boolean;
createdAt: number;
@ -196,6 +205,7 @@ export interface DaemonScheduledTask {
export interface DaemonCreateScheduledTaskRequest {
cron: string;
prompt: string;
mentions?: DaemonScheduledTaskMention[];
/** Omit or null for an unnamed task. */
name?: string | null;
/** Defaults to true (fire on every match until deleted/expired). */
@ -209,6 +219,7 @@ export interface DaemonCreateScheduledTaskRequest {
export interface DaemonUpdateScheduledTaskRequest {
cron?: string;
prompt?: string;
mentions?: DaemonScheduledTaskMention[];
name?: string | null;
recurring?: boolean;
enabled?: boolean;