fix(cron): keep scheduled task message editable after inserting a skill (#1223)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Haze
2026-08-04 14:48:59 +08:00
committed by GitHub
co-authored by Cursor
parent 6dd39e8f7c
commit bbbf6d5bb8
2 changed files with 215 additions and 74 deletions
+63 -11
View File
@@ -2,7 +2,16 @@
* Cron Page
* Manage scheduled tasks
*/
import { useEffect, useState, useCallback, useMemo, useRef, type ReactNode, type SelectHTMLAttributes } from 'react';
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useMemo,
useRef,
type ReactNode,
type SelectHTMLAttributes,
} from 'react';
import {
Plus,
Clock,
@@ -74,6 +83,10 @@ const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [
type SkillTokenRange = { start: number; end: number };
// The message textarea grows with its content up to this height, after which it
// scrolls internally and the highlight overlay is scroll-synced to match it.
const CRON_MESSAGE_MAX_HEIGHT = 200;
function getSkillPrefix(skillName: string): string {
return `/${skillName} `;
}
@@ -606,6 +619,7 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial
const [skillsLoading, setSkillsLoading] = useState(false);
const [skillsError, setSkillsError] = useState<string | null>(null);
const messageRef = useRef<HTMLTextAreaElement>(null);
const messageOverlayRef = useRef<HTMLDivElement>(null);
const skillPickerRef = useRef<HTMLDivElement>(null);
const [prevOpen, setPrevOpen] = useState(open);
@@ -700,16 +714,49 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial
};
}, [skillPickerOpen]);
const moveMessageCaretTo = useCallback((position: number) => {
// The highlight overlay is absolutely positioned on top of the textarea and is
// the only visible copy of the text once a skill token exists, so it has to
// follow the textarea's scroll offset or edits below the fold become invisible.
const syncMessageOverlayScroll = useCallback(() => {
const textarea = messageRef.current;
const overlay = messageOverlayRef.current;
if (!textarea || !overlay) return;
overlay.scrollTop = textarea.scrollTop;
overlay.scrollLeft = textarea.scrollLeft;
}, []);
useLayoutEffect(() => {
const textarea = messageRef.current;
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(position, position);
requestAnimationFrame(() => {
messageRef.current?.focus();
messageRef.current?.setSelectionRange(position, position);
});
}, []);
const previousScrollTop = textarea.scrollTop;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, CRON_MESSAGE_MAX_HEIGHT)}px`;
textarea.scrollTop = previousScrollTop;
// On platforms with classic scrollbars the textarea's scrollbar gutter eats
// into its content width; the overlay has to lose the same width or the two
// copies of the text wrap at different columns.
const overlay = messageOverlayRef.current;
if (overlay) {
overlay.style.paddingRight = `${Math.max(0, textarea.offsetWidth - textarea.clientWidth)}px`;
}
syncMessageOverlayScroll();
}, [message, open, syncMessageOverlayScroll]);
const moveMessageCaretTo = useCallback(
(position: number) => {
const textarea = messageRef.current;
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(position, position);
syncMessageOverlayScroll();
requestAnimationFrame(() => {
messageRef.current?.focus();
messageRef.current?.setSelectionRange(position, position);
syncMessageOverlayScroll();
});
},
[syncMessageOverlayScroll],
);
const normalizeMessageSelection = useCallback(() => {
if (skillTokenRanges.length === 0) return;
@@ -790,9 +837,10 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial
messageRef.current?.focus();
const cursorPosition = selectionStart + leadingSpace.length + nextToken.length;
messageRef.current?.setSelectionRange(cursorPosition, cursorPosition);
syncMessageOverlayScroll();
});
},
[message],
[message, syncMessageOverlayScroll],
);
const updateSchedule = useCallback(
(patch: Partial<ScheduleFormState>) => setScheduleForm((prev) => ({ ...prev, ...patch })),
@@ -1026,6 +1074,8 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial
{skillTokenRanges.length > 0 && (
<div
aria-hidden="true"
ref={messageOverlayRef}
data-testid="cron-message-highlight"
className="pointer-events-none absolute inset-0 z-20 overflow-hidden whitespace-pre-wrap break-words font-mono text-meta md:text-sm leading-[18px] text-foreground"
>
{renderHighlightedCronMessage(message, skillTokenRanges)}
@@ -1040,9 +1090,11 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial
onKeyDown={handleMessageKeyDown}
onSelect={normalizeMessageSelection}
onClick={normalizeMessageSelection}
onScroll={syncMessageOverlayScroll}
rows={3}
style={{ maxHeight: CRON_MESSAGE_MAX_HEIGHT }}
className={cn(
'relative min-h-[60px] w-full resize-none border-0 bg-transparent p-0 font-mono text-meta md:text-sm leading-[18px] text-foreground placeholder:text-foreground/40 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0',
'relative min-h-[60px] w-full resize-none border-0 bg-transparent p-0 font-mono text-meta md:text-sm leading-[18px] text-foreground placeholder:text-foreground/40 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 [scrollbar-gutter:stable]',
skillTokenRanges.length > 0
? 'z-0 text-transparent caret-foreground selection:bg-primary/20'
: 'z-10',
+152 -63
View File
@@ -1,3 +1,5 @@
import type { ElectronApplication } from '@playwright/test';
import { completeSetup, expect, installIpcMocks, test } from './fixtures/electron';
function stableStringify(value: unknown): string {
@@ -9,76 +11,79 @@ function stableStringify(value: unknown): string {
return `{${entries.join(',')}}`;
}
test.describe('cron skill picker', () => {
test('inserts a skill token into the scheduled task message without preview', async ({ electronApp, page }) => {
await installIpcMocks(electronApp, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
gatewayRpc: {},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
async function installCronSkillMocks(electronApp: ElectronApplication) {
await installIpcMocks(electronApp, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
gatewayRpc: {},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
},
json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
},
[stableStringify(['/api/cron/jobs', 'GET'])]: {
},
[stableStringify(['/api/cron/jobs', 'GET'])]: {
ok: true,
data: { status: 200, ok: true, json: [] },
},
[stableStringify(['/api/channels/accounts', 'GET'])]: {
ok: true,
data: { status: 200, ok: true, json: { success: true, channels: [] } },
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
data: { status: 200, ok: true, json: [] },
},
[stableStringify(['/api/channels/accounts', 'GET'])]: {
ok: true,
data: { status: 200, ok: true, json: { success: true, channels: [] } },
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
agents: [{
id: 'main',
name: 'Main Agent',
isDefault: true,
modelDisplay: 'Default Model',
modelRef: 'openai/gpt-5.5',
overrideModelRef: null,
inheritedModel: true,
workspace: '/tmp/clawx-main-agent',
agentDir: '/tmp/clawx-main-agent/agent',
mainSessionKey: 'main/default',
channelTypes: [],
}],
defaultAgentId: 'main',
defaultModelRef: 'openai/gpt-5.5',
configuredChannelTypes: [],
channelOwners: {},
channelAccountOwners: {},
},
},
},
[stableStringify(['/api/skills/quick-access', 'POST'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
skills: [{
name: 'create-skill',
description: 'Create and refine reusable skills.',
source: 'workspace',
sourceLabel: 'Workspace',
manifestPath: '/tmp/clawx-main-agent/skill/create-skill/SKILL.md',
baseDir: '/tmp/clawx-main-agent/skill/create-skill',
}],
},
json: {
agents: [{
id: 'main',
name: 'Main Agent',
isDefault: true,
modelDisplay: 'Default Model',
modelRef: 'openai/gpt-5.5',
overrideModelRef: null,
inheritedModel: true,
workspace: '/tmp/clawx-main-agent',
agentDir: '/tmp/clawx-main-agent/agent',
mainSessionKey: 'main/default',
channelTypes: [],
}],
defaultAgentId: 'main',
defaultModelRef: 'openai/gpt-5.5',
configuredChannelTypes: [],
channelOwners: {},
channelAccountOwners: {},
},
},
},
});
[stableStringify(['/api/skills/quick-access', 'POST'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
skills: [{
name: 'create-skill',
description: 'Create and refine reusable skills.',
source: 'workspace',
sourceLabel: 'Workspace',
manifestPath: '/tmp/clawx-main-agent/skill/create-skill/SKILL.md',
baseDir: '/tmp/clawx-main-agent/skill/create-skill',
}],
},
},
},
},
});
}
test.describe('cron skill picker', () => {
test('inserts a skill token into the scheduled task message without preview', async ({ electronApp, page }) => {
await installCronSkillMocks(electronApp);
await completeSetup(page);
await page.getByTestId('sidebar-nav-cron').click();
@@ -101,4 +106,88 @@ test.describe('cron skill picker', () => {
// The cron dialog renders skill tokens as non-interactive spans (no preview).
await expect(token).toHaveJSProperty('tagName', 'SPAN');
});
test('grows the message field with its content instead of scrolling at three rows', async ({
electronApp,
page,
}) => {
await installCronSkillMocks(electronApp);
await completeSetup(page);
await page.getByTestId('sidebar-nav-cron').click();
await page.getByTestId('cron-new-task-button').click();
await expect(page.getByTestId('cron-task-dialog')).toBeVisible();
const message = page.locator('#message');
const heightOf = () => message.evaluate((el) => el.clientHeight);
await message.click();
const emptyHeight = await heightOf();
expect(emptyHeight).toBeGreaterThanOrEqual(60);
await message.fill(Array.from({ length: 6 }, (_, index) => `line ${index}`).join('\n'));
await expect.poll(heightOf).toBeGreaterThan(emptyHeight);
// Long content stops growing at the cap and scrolls internally from there.
await message.fill(Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n'));
await expect.poll(heightOf).toBeLessThanOrEqual(200);
expect(await message.evaluate((el) => el.scrollHeight)).toBeGreaterThan(await heightOf());
});
test('keeps the highlight overlay scroll-synced so the message stays editable after inserting a skill', async ({
electronApp,
page,
}) => {
await installCronSkillMocks(electronApp);
await completeSetup(page);
await page.getByTestId('sidebar-nav-cron').click();
await page.getByTestId('cron-new-task-button').click();
await expect(page.getByTestId('cron-task-dialog')).toBeVisible();
const message = page.locator('#message');
await message.click();
await message.fill(Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n'));
await page.getByTestId('cron-skill-button').click();
const skillOption = page.getByTestId('cron-skill-option-create-skill');
await expect(skillOption).toBeVisible();
await skillOption.click();
const overlay = page.getByTestId('cron-message-highlight');
await expect(overlay).toBeAttached();
await expect(message).toHaveValue(/\/create-skill {2}$/);
const scrollOffsets = async () => ({
textarea: await message.evaluate((el) => el.scrollTop),
overlay: await overlay.evaluate((el) => el.scrollTop),
});
// Inserting at the end scrolls the caret into view; the overlay must follow,
// otherwise the only visible copy of the text stays frozen at the top.
await expect.poll(async () => (await scrollOffsets()).textarea).toBeGreaterThan(0);
await expect
.poll(async () => {
const { textarea, overlay: overlayTop } = await scrollOffsets();
return Math.abs(textarea - overlayTop);
})
.toBeLessThanOrEqual(1);
// Typing after the token keeps editing the value and keeps the overlay aligned.
await page.keyboard.type('after token');
await expect(message).toHaveValue(/\/create-skill {2}after token$/);
await expect(overlay).toContainText('after token');
await expect
.poll(async () => {
const { textarea, overlay: overlayTop } = await scrollOffsets();
return Math.abs(textarea - overlayTop);
})
.toBeLessThanOrEqual(1);
// Scrolling the field back up moves the rendered text with it.
await message.evaluate((el) => {
el.scrollTop = 0;
});
await expect.poll(async () => (await scrollOffsets()).overlay).toBe(0);
});
});