diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts index c5da3f7071..47da5105e9 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.test.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -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); diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts index 9a99d781eb..6aa25e644a 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -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; + 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({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eb8b832850..1ae2c5cd36 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/services/cronTasksFile.test.ts b/packages/core/src/services/cronTasksFile.test.ts index e3a268fc2a..df1f999834 100644 --- a/packages/core/src/services/cronTasksFile.test.ts +++ b/packages/core/src/services/cronTasksFile.test.ts @@ -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, diff --git a/packages/core/src/services/cronTasksFile.ts b/packages/core/src/services/cronTasksFile.ts index 7549ea38f4..17ced03520 100644 --- a/packages/core/src/services/cronTasksFile.ts +++ b/packages/core/src/services/cronTasksFile.ts @@ -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; + 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; @@ -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'])) && diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 6cf2b93481..f77031e561 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4805,6 +4805,7 @@ export function App({
{ // Start a FRESH session and jump to it so the task- diff --git a/packages/web-shell/client/components/AtMentionPanel.tsx b/packages/web-shell/client/components/AtMentionPanel.tsx index 7b10bd34b0..71bf50d80d 100644 --- a/packages/web-shell/client/components/AtMentionPanel.tsx +++ b/packages/web-shell/client/components/AtMentionPanel.tsx @@ -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; @@ -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>([]); const searchInputRef = useRef(null); const [anchorRect, setAnchorRect] = useState<{ left: number; - bottom: number; + bottom: number | null; + top: number | null; width: number; } | null>(null); const [themeVars, setThemeVars] = useState({}); @@ -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( -
+ const panel = ( +
-
, - document.body, +
); + + return portal ? createPortal(panel, document.body) : panel; } diff --git a/packages/web-shell/client/components/dialogs/DialogShell.module.css b/packages/web-shell/client/components/dialogs/DialogShell.module.css index f2e7c2e3da..051626963b 100644 --- a/packages/web-shell/client/components/dialogs/DialogShell.module.css +++ b/packages/web-shell/client/components/dialogs/DialogShell.module.css @@ -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; diff --git a/packages/web-shell/client/components/dialogs/ScheduledTaskPromptEditor.tsx b/packages/web-shell/client/components/dialogs/ScheduledTaskPromptEditor.tsx new file mode 100644 index 0000000000..18e22feb17 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/ScheduledTaskPromptEditor.tsx @@ -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[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 && ( +