mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(web-shell): support mentions in scheduled task prompts
This commit is contained in:
parent
48e5d5d0d7
commit
755d505923
16 changed files with 1087 additions and 29 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'])) &&
|
||||
|
|
|
|||
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export type {
|
|||
DaemonGlobOptions,
|
||||
DaemonGlobResult,
|
||||
DaemonScheduledTask,
|
||||
DaemonScheduledTaskMention,
|
||||
DaemonScheduledTaskRun,
|
||||
DaemonCreateScheduledTaskRequest,
|
||||
DaemonUpdateScheduledTaskRequest,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type {
|
|||
DaemonGlobOptions,
|
||||
DaemonGlobResult,
|
||||
DaemonScheduledTask,
|
||||
DaemonScheduledTaskMention,
|
||||
DaemonScheduledTaskRun,
|
||||
DaemonCreateScheduledTaskRequest,
|
||||
DaemonUpdateScheduledTaskRequest,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue