mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix(cron): keep scheduled task message editable after inserting a skill (#1223)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+63
-11
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user